Ali2206 commited on
Commit
8db2f16
·
verified ·
1 Parent(s): 1be9386

Update api/routes.py

Browse files
Files changed (1) hide show
  1. api/routes.py +6 -941
api/routes.py CHANGED
@@ -1,944 +1,9 @@
1
- from fastapi import APIRouter, HTTPException, Depends, Query, status, Response
2
- from fastapi.security import OAuth2PasswordRequestForm
3
- from models.schemas import SignupForm, TokenResponse, PatientCreate, DoctorCreate, AppointmentCreate
4
- from db.mongo import users_collection, patients_collection, appointments_collection
5
- from core.security import hash_password, verify_password, create_access_token, get_current_user
6
- from datetime import datetime, timedelta
7
- from bson import ObjectId
8
- from bson.errors import InvalidId
9
- from typing import Optional, List, Dict
10
- from pydantic import BaseModel, Field
11
- from pymongo import UpdateOne, InsertOne, IndexModel
12
- from pymongo.errors import BulkWriteError
13
- import httpx
14
- import os
15
- import json
16
- from pathlib import Path
17
- import glob
18
- from motor.motor_asyncio import AsyncIOMotorClient
19
- import logging
20
- import time
21
- import uuid
22
- import re
23
- import subprocess
24
- from tempfile import NamedTemporaryFile, TemporaryDirectory
25
- from string import Template
26
-
27
- # Configure logging
28
- logging.basicConfig(
29
- level=logging.INFO,
30
- format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
31
- )
32
- logger = logging.getLogger(__name__)
33
 
34
  router = APIRouter()
35
 
36
- # Configuration
37
- BASE_DIR = Path(__file__).resolve().parent.parent
38
- SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
39
- os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
40
-
41
- # Models
42
- class Note(BaseModel):
43
- date: str = Field(..., example="2023-01-01T12:00:00Z")
44
- type: str = Field(..., example="Progress Note")
45
- text: str = Field(..., example="Patient reported improvement in symptoms")
46
- author: Optional[str] = Field(None, example="Dr. Smith")
47
-
48
- class Condition(BaseModel):
49
- id: str = Field(..., example="cond123")
50
- code: str = Field(..., example="Hypertension")
51
- status: str = Field(..., example="Active")
52
- onset_date: str = Field(..., example="2020-05-15")
53
- recorded_date: Optional[str] = Field(None, example="2020-05-16")
54
- verification_status: Optional[str] = Field(None, example="Confirmed")
55
-
56
- class Medication(BaseModel):
57
- id: str = Field(..., example="med123")
58
- name: str = Field(..., example="Lisinopril 10mg")
59
- status: str = Field(..., example="Active")
60
- prescribed_date: str = Field(..., example="2021-02-10")
61
- requester: Optional[str] = Field(None, example="Dr. Smith")
62
- dosage: Optional[str] = Field(None, example="10mg daily")
63
-
64
- class Encounter(BaseModel):
65
- id: str = Field(..., example="enc123")
66
- type: str = Field(..., example="Outpatient Visit")
67
- status: str = Field(..., example="Finished")
68
- period: Dict = Field(..., example={"start": "2023-01-01T10:00:00Z"})
69
- service_provider: Optional[str] = Field(None, example="City Hospital")
70
-
71
- class PatientUpdate(BaseModel):
72
- notes: Optional[List[Note]] = None
73
- conditions: Optional[List[Condition]] = None
74
- medications: Optional[List[Medication]] = None
75
-
76
- # Language mapping for MongoDB compatibility
77
- LANGUAGE_MAP = {
78
- 'English (United States)': 'en',
79
- 'English': 'en',
80
- 'Spanish (United States)': 'es',
81
- 'Spanish': 'es',
82
- # Add more mappings as needed
83
- }
84
-
85
- # Indexes
86
- async def create_indexes():
87
- try:
88
- await patients_collection.create_indexes([
89
- IndexModel([("fhir_id", 1)], unique=True),
90
- IndexModel([("full_name", "text")]),
91
- IndexModel([("date_of_birth", 1)]),
92
- IndexModel([("notes.date", -1)])
93
- ])
94
- logger.info("Database indexes created successfully")
95
- except Exception as e:
96
- logger.error(f"Failed to create indexes: {str(e)}")
97
- raise
98
-
99
- # Helper Functions
100
- def calculate_age(birth_date: str) -> Optional[int]:
101
- if not birth_date:
102
- return None
103
- try:
104
- birth_date = datetime.strptime(birth_date.split('T')[0], "%Y-%m-%d")
105
- today = datetime.now()
106
- return today.year - birth_date.year - (
107
- (today.month, today.day) < (birth_date.month, birth_date.day))
108
- except ValueError as e:
109
- logger.warning(f"Invalid birth date format: {birth_date}, error: {str(e)}")
110
- return None
111
-
112
- def standardize_language(language: str) -> str:
113
- """Convert language to MongoDB-compatible language code."""
114
- if not language:
115
- return 'en' # Default to English
116
- return LANGUAGE_MAP.get(language, 'en')
117
-
118
- def escape_latex_special_chars(text: str) -> str:
119
- """Escape special LaTeX characters to prevent compilation errors."""
120
- if not isinstance(text, str):
121
- return ""
122
- replacements = {
123
- "&": "\\&",
124
- "%": "\\%",
125
- "$": "\\$",
126
- "#": "\\#",
127
- "_": "\\_",
128
- "{": "\\{",
129
- "}": "\\}",
130
- "~": "\\textasciitilde{}",
131
- "^": "\\textasciicircum{}",
132
- "\\": "\\textbackslash{}"
133
- }
134
- for char, escape in replacements.items():
135
- text = text.replace(char, escape)
136
- return text
137
-
138
- def hyphenate_long_strings(text: str, max_length: int = 10) -> str:
139
- """Insert LaTeX hyphenation points into long strings to prevent overfull hboxes."""
140
- if not isinstance(text, str) or len(text) <= max_length:
141
- return text
142
- result = ""
143
- for i, char in enumerate(text):
144
- result += char
145
- if (i + 1) % max_length == 0 and (i + 1) != len(text):
146
- result += "\\-"
147
- return result
148
-
149
- def format_timestamp(timestamp: str) -> str:
150
- """Shorten a timestamp to YYYY-MM-DD format."""
151
- if not isinstance(timestamp, str):
152
- return ""
153
- try:
154
- dt = datetime.strptime(timestamp, "%Y-%m-%dT%H:%M:%S%z")
155
- return dt.strftime("%Y-%m-%d")
156
- except ValueError:
157
- return timestamp
158
-
159
- async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
160
- logger.debug(f"Processing patient from file: {file_path}")
161
- patient_data = {}
162
- notes = []
163
- conditions = []
164
- medications = []
165
- encounters = []
166
-
167
- # Validate bundle structure
168
- if not isinstance(bundle, dict) or 'entry' not in bundle:
169
- logger.error(f"Invalid FHIR bundle structure in {file_path}")
170
- return None
171
-
172
- for entry in bundle.get('entry', []):
173
- resource = entry.get('resource', {})
174
- resource_type = resource.get('resourceType')
175
-
176
- if not resource_type:
177
- logger.warning(f"Skipping entry with missing resourceType in {file_path}")
178
- continue
179
-
180
- try:
181
- if resource_type == 'Patient':
182
- name = resource.get('name', [{}])[0]
183
- address = resource.get('address', [{}])[0]
184
-
185
- patient_data = {
186
- 'fhir_id': resource.get('id'),
187
- 'full_name': f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip(),
188
- 'gender': resource.get('gender', 'unknown'),
189
- 'date_of_birth': resource.get('birthDate', ''),
190
- 'address': ' '.join(address.get('line', [''])),
191
- 'city': address.get('city', ''),
192
- 'state': address.get('state', ''),
193
- 'postal_code': address.get('postalCode', ''),
194
- 'country': address.get('country', ''),
195
- 'marital_status': resource.get('maritalStatus', {}).get('text', ''),
196
- 'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
197
- 'source': 'synthea',
198
- 'last_updated': datetime.utcnow().isoformat()
199
- }
200
-
201
- elif resource_type == 'Encounter':
202
- encounter = {
203
- 'id': resource.get('id'),
204
- 'type': resource.get('type', [{}])[0].get('text', ''),
205
- 'status': resource.get('status'),
206
- 'period': resource.get('period', {}),
207
- 'service_provider': resource.get('serviceProvider', {}).get('display', '')
208
- }
209
- encounters.append(encounter)
210
-
211
- for note in resource.get('note', []):
212
- if note.get('text'):
213
- notes.append({
214
- 'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
215
- 'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
216
- 'text': note.get('text'),
217
- 'context': f"Encounter: {encounter.get('type')}",
218
- 'author': 'System Generated'
219
- })
220
-
221
- elif resource_type == 'Condition':
222
- conditions.append({
223
- 'id': resource.get('id'),
224
- 'code': resource.get('code', {}).get('text', ''),
225
- 'status': resource.get('clinicalStatus', {}).get('text', ''),
226
- 'onset_date': resource.get('onsetDateTime'),
227
- 'recorded_date': resource.get('recordedDate'),
228
- 'verification_status': resource.get('verificationStatus', {}).get('text', '')
229
- })
230
-
231
- elif resource_type == 'MedicationRequest':
232
- medications.append({
233
- 'id': resource.get('id'),
234
- 'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
235
- 'status': resource.get('status'),
236
- 'prescribed_date': resource.get('authoredOn'),
237
- 'requester': resource.get('requester', {}).get('display', ''),
238
- 'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
239
- })
240
-
241
- except Exception as e:
242
- logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
243
- continue
244
-
245
- if patient_data:
246
- patient_data.update({
247
- 'notes': notes,
248
- 'conditions': conditions,
249
- 'medications': medications,
250
- 'encounters': encounters,
251
- 'import_date': datetime.utcnow().isoformat()
252
- })
253
- logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
254
- return patient_data
255
- logger.warning(f"No valid patient data found in {file_path}")
256
- return None
257
-
258
- # Routes
259
- @router.post("/ehr/import", status_code=status.HTTP_201_CREATED)
260
- async def import_patients(
261
- limit: int = Query(100, ge=1, le=1000),
262
- current_user: dict = Depends(get_current_user)
263
- ):
264
- request_id = str(uuid.uuid4())
265
- logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
266
- start_time = time.time()
267
-
268
- if current_user.get('role') not in ['admin', 'doctor']:
269
- logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
270
- raise HTTPException(
271
- status_code=status.HTTP_403_FORBIDDEN,
272
- detail="Only administrators and doctors can import data"
273
- )
274
-
275
- try:
276
- await create_indexes()
277
-
278
- if not SYNTHEA_DATA_DIR.exists():
279
- logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
280
- raise HTTPException(
281
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
282
- detail="Data directory not found"
283
- )
284
-
285
- # Filter out non-patient files
286
- files = [
287
- f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
288
- if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
289
- ]
290
- if not files:
291
- logger.warning("No valid patient JSON files found in synthea data directory")
292
- return {
293
- "status": "success",
294
- "message": "No patient data files found",
295
- "imported": 0,
296
- "request_id": request_id
297
- }
298
-
299
- operations = []
300
- imported = 0
301
- errors = []
302
-
303
- for file_path in files[:limit]:
304
- try:
305
- logger.debug(f"Processing file: {file_path}")
306
-
307
- # Check file accessibility
308
- if not os.path.exists(file_path):
309
- logger.error(f"File not found: {file_path}")
310
- errors.append(f"File not found: {file_path}")
311
- continue
312
-
313
- # Check file size
314
- file_size = os.path.getsize(file_path)
315
- if file_size == 0:
316
- logger.warning(f"Empty file: {file_path}")
317
- errors.append(f"Empty file: {file_path}")
318
- continue
319
-
320
- with open(file_path, 'r', encoding='utf-8') as f:
321
- try:
322
- bundle = json.load(f)
323
- except json.JSONDecodeError as je:
324
- logger.error(f"Invalid JSON in {file_path}: {str(je)}")
325
- errors.append(f"Invalid JSON in {file_path}: {str(je)}")
326
- continue
327
-
328
- patient = await process_synthea_patient(bundle, file_path)
329
- if patient:
330
- if not patient.get('fhir_id'):
331
- logger.warning(f"Missing FHIR ID in patient data from {file_path}")
332
- errors.append(f"Missing FHIR ID in {file_path}")
333
- continue
334
-
335
- operations.append(UpdateOne(
336
- {"fhir_id": patient['fhir_id']},
337
- {"$setOnInsert": patient},
338
- upsert=True
339
- ))
340
- imported += 1
341
- else:
342
- logger.warning(f"No valid patient data in {file_path}")
343
- errors.append(f"No valid patient data in {file_path}")
344
-
345
- except Exception as e:
346
- logger.error(f"Error processing {file_path}: {str(e)}")
347
- errors.append(f"Error in {file_path}: {str(e)}")
348
- continue
349
-
350
- response = {
351
- "status": "success",
352
- "imported": imported,
353
- "errors": errors,
354
- "request_id": request_id,
355
- "duration_seconds": time.time() - start_time
356
- }
357
-
358
- if operations:
359
- try:
360
- result = await patients_collection.bulk_write(operations, ordered=False)
361
- response.update({
362
- "upserted": result.upserted_count,
363
- "existing": len(operations) - result.upserted_count
364
- })
365
- logger.info(f"Import request {request_id} completed: {imported} patients processed, "
366
- f"{result.upserted_count} upserted, {len(errors)} errors")
367
- except BulkWriteError as bwe:
368
- logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
369
- response.update({
370
- "upserted": bwe.details.get('nUpserted', 0),
371
- "existing": len(operations) - bwe.details.get('nUpserted', 0),
372
- "write_errors": [
373
- f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
374
- ]
375
- })
376
- logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
377
- f"{response['upserted']} upserted, {len(errors)} errors")
378
- except Exception as e:
379
- logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
380
- raise HTTPException(
381
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
382
- detail=f"Database operation failed: {str(e)}"
383
- )
384
- else:
385
- logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
386
- response["message"] = "No new patients found to import"
387
-
388
- return response
389
-
390
- except HTTPException:
391
- raise
392
- except Exception as e:
393
- logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
394
- raise HTTPException(
395
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
396
- detail=f"Import failed: {str(e)}"
397
- )
398
-
399
- @router.get("/ehr/patients", response_model=List[dict])
400
- async def list_patients(
401
- search: Optional[str] = Query(None),
402
- min_notes: int = Query(0, ge=0),
403
- min_conditions: int = Query(0, ge=0),
404
- limit: int = Query(100, ge=1, le=500),
405
- skip: int = Query(0, ge=0)
406
- ):
407
- logger.info(f"Listing patients with search: {search}, limit: {limit}, skip: {skip}")
408
- query = {"source": "synthea"}
409
-
410
- if search:
411
- query["$or"] = [
412
- {"full_name": {"$regex": search, "$options": "i"}},
413
- {"fhir_id": search}
414
- ]
415
-
416
- if min_notes > 0:
417
- query[f"notes.{min_notes-1}"] = {"$exists": True}
418
-
419
- if min_conditions > 0:
420
- query[f"conditions.{min_conditions-1}"] = {"$exists": True}
421
-
422
- # Removed $slice to return full arrays for the frontend
423
- projection = {
424
- "fhir_id": 1,
425
- "full_name": 1,
426
- "gender": 1,
427
- "date_of_birth": 1,
428
- "city": 1,
429
- "state": 1,
430
- "conditions": 1,
431
- "medications": 1,
432
- "encounters": 1,
433
- "notes": 1
434
- }
435
-
436
- try:
437
- cursor = patients_collection.find(query, projection).skip(skip).limit(limit)
438
- patients = []
439
-
440
- async for patient in cursor:
441
- patients.append({
442
- "id": str(patient["_id"]),
443
- "fhir_id": patient.get("fhir_id"),
444
- "full_name": patient.get("full_name"),
445
- "gender": patient.get("gender"),
446
- "date_of_birth": patient.get("date_of_birth"),
447
- "city": patient.get("city"),
448
- "state": patient.get("state"),
449
- "conditions": patient.get("conditions", []),
450
- "medications": patient.get("medications", []),
451
- "encounters": patient.get("encounters", []),
452
- "notes": patient.get("notes", []),
453
- "age": calculate_age(patient.get("date_of_birth")),
454
- "stats": {
455
- "notes": len(patient.get("notes", [])),
456
- "conditions": len(patient.get("conditions", [])),
457
- "medications": len(patient.get("medications", []))
458
- }
459
- })
460
-
461
- logger.info(f"Retrieved {len(patients)} patients")
462
- return patients
463
-
464
- except Exception as e:
465
- logger.error(f"Failed to list patients: {str(e)}")
466
- raise HTTPException(
467
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
468
- detail=f"Failed to retrieve patients: {str(e)}"
469
- )
470
-
471
- @router.get("/ehr/patients/{patient_id}", response_model=dict)
472
- async def get_patient(patient_id: str):
473
- logger.info(f"Retrieving patient: {patient_id}")
474
- try:
475
- patient = await patients_collection.find_one({
476
- "$or": [
477
- {"_id": ObjectId(patient_id)},
478
- {"fhir_id": patient_id}
479
- ]
480
- })
481
-
482
- if not patient:
483
- logger.warning(f"Patient not found: {patient_id}")
484
- raise HTTPException(
485
- status_code=status.HTTP_404_NOT_FOUND,
486
- detail="Patient not found"
487
- )
488
-
489
- response = {
490
- "demographics": {
491
- "id": str(patient["_id"]),
492
- "fhir_id": patient.get("fhir_id"),
493
- "full_name": patient.get("full_name"),
494
- "gender": patient.get("gender"),
495
- "date_of_birth": patient.get("date_of_birth"),
496
- "age": calculate_age(patient.get("date_of_birth")),
497
- "address": {
498
- "line": patient.get("address"),
499
- "city": patient.get("city"),
500
- "state": patient.get("state"),
501
- "postal_code": patient.get("postal_code"),
502
- "country": patient.get("country")
503
- },
504
- "marital_status": patient.get("marital_status"),
505
- "language": patient.get("language")
506
- },
507
- "clinical_data": {
508
- "notes": patient.get("notes", []),
509
- "conditions": patient.get("conditions", []),
510
- "medications": patient.get("medications", []),
511
- "encounters": patient.get("encounters", [])
512
- },
513
- "metadata": {
514
- "source": patient.get("source"),
515
- "import_date": patient.get("import_date"),
516
- "last_updated": patient.get("last_updated")
517
- }
518
- }
519
-
520
- logger.info(f"Successfully retrieved patient: {patient_id}")
521
- return response
522
-
523
- except ValueError as ve:
524
- logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
525
- raise HTTPException(
526
- status_code=status.HTTP_400_BAD_REQUEST,
527
- detail="Invalid patient ID format"
528
- )
529
- except Exception as e:
530
- logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
531
- raise HTTPException(
532
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
533
- detail=f"Failed to retrieve patient: {str(e)}"
534
- )
535
-
536
- @router.post("/ehr/patients/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
537
- async def add_note(
538
- patient_id: str,
539
- note: Note,
540
- current_user: dict = Depends(get_current_user)
541
- ):
542
- logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
543
- if current_user.get('role') not in ['doctor', 'admin']:
544
- logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
545
- raise HTTPException(
546
- status_code=status.HTTP_403_FORBIDDEN,
547
- detail="Only clinicians can add notes"
548
- )
549
-
550
- try:
551
- note_data = note.dict()
552
- note_data.update({
553
- "author": current_user.get('full_name', 'System'),
554
- "timestamp": datetime.utcnow().isoformat()
555
- })
556
-
557
- result = await patients_collection.update_one(
558
- {"$or": [
559
- {"_id": ObjectId(patient_id)},
560
- {"fhir_id": patient_id}
561
- ]},
562
- {
563
- "$push": {"notes": note_data},
564
- "$set": {"last_updated": datetime.utcnow().isoformat()}
565
- }
566
- )
567
-
568
- if result.modified_count == 0:
569
- logger.warning(f"Patient not found for note addition: {patient_id}")
570
- raise HTTPException(
571
- status_code=status.HTTP_404_NOT_FOUND,
572
- detail="Patient not found"
573
- )
574
-
575
- logger.info(f"Note added successfully for patient {patient_id}")
576
- return {"status": "success", "message": "Note added"}
577
-
578
- except ValueError as ve:
579
- logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
580
- raise HTTPException(
581
- status_code=status.HTTP_400_BAD_REQUEST,
582
- detail="Invalid patient ID format"
583
- )
584
- except Exception as e:
585
- logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
586
- raise HTTPException(
587
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
588
- detail=f"Failed to add note: {str(e)}"
589
- )
590
-
591
- @router.get("/ehr/patients/{patient_id}/pdf", response_class=Response)
592
- async def generate_patient_pdf(patient_id: str, current_user: dict = Depends(get_current_user)):
593
- # Suppress logging for this route
594
- logger.setLevel(logging.CRITICAL)
595
-
596
- try:
597
- if current_user.get('role') not in ['doctor', 'admin']:
598
- raise HTTPException(status_code=403, detail="Only clinicians can generate patient PDFs")
599
-
600
- # Determine if patient_id is ObjectId or fhir_id
601
- try:
602
- obj_id = ObjectId(patient_id)
603
- query = {"$or": [{"_id": obj_id}, {"fhir_id": patient_id}]}
604
- except InvalidId:
605
- query = {"fhir_id": patient_id}
606
-
607
- patient = await patients_collection.find_one(query)
608
- if not patient:
609
- raise HTTPException(status_code=404, detail="Patient not found")
610
-
611
- # Prepare table content
612
- notes = patient.get("notes", [])
613
- notes_content = ""
614
- if notes:
615
- notes_content = "\\toprule\n" + " \\\\\n".join(
616
- "{} & {} & {}".format(
617
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(n.get("date", "") or ""))),
618
- escape_latex_special_chars(hyphenate_long_strings(n.get("type", "") or "")),
619
- escape_latex_special_chars(hyphenate_long_strings(n.get("text", "") or ""))
620
- )
621
- for n in notes
622
- ) + "\n\\bottomrule"
623
- else:
624
- notes_content = "\\multicolumn{3}{l}{No notes available}"
625
-
626
- conditions = patient.get("conditions", [])
627
- conditions_content = ""
628
- if conditions:
629
- conditions_content = "\\toprule\n" + " \\\\\n".join(
630
- "{} & {} & {} & {} & {}".format(
631
- escape_latex_special_chars(hyphenate_long_strings(c.get("id", "") or "")),
632
- escape_latex_special_chars(hyphenate_long_strings(c.get("code", "") or "")),
633
- escape_latex_special_chars(hyphenate_long_strings(c.get("status", "") or "")),
634
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(c.get("onset_date", "") or ""))),
635
- escape_latex_special_chars(hyphenate_long_strings(c.get("verification_status", "") or ""))
636
- )
637
- for c in conditions
638
- ) + "\n\\bottomrule"
639
- else:
640
- conditions_content = "\\multicolumn{5}{l}{No conditions available}"
641
-
642
- medications = patient.get("medications", [])
643
- medications_content = ""
644
- if medications:
645
- medications_content = "\\toprule\n" + " \\\\\n".join(
646
- "{} & {} & {} & {} & {}".format(
647
- escape_latex_special_chars(hyphenate_long_strings(m.get("id", "") or "")),
648
- escape_latex_special_chars(hyphenate_long_strings(m.get("name", "") or "")),
649
- escape_latex_special_chars(hyphenate_long_strings(m.get("status", "") or "")),
650
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(m.get("prescribed_date", "") or ""))),
651
- escape_latex_special_chars(hyphenate_long_strings(m.get("dosage", "") or ""))
652
- )
653
- for m in medications
654
- ) + "\n\\bottomrule"
655
- else:
656
- medications_content = "\\multicolumn{5}{l}{No medications available}"
657
-
658
- encounters = patient.get("encounters", [])
659
- encounters_content = ""
660
- if encounters:
661
- encounters_content = "\\toprule\n" + " \\\\\n".join(
662
- "{} & {} & {} & {} & {}".format(
663
- escape_latex_special_chars(hyphenate_long_strings(e.get("id", "") or "")),
664
- escape_latex_special_chars(hyphenate_long_strings(e.get("type", "") or "")),
665
- escape_latex_special_chars(hyphenate_long_strings(e.get("status", "") or "")),
666
- escape_latex_special_chars(hyphenate_long_strings(format_timestamp(e.get("period", {}).get("start", "") or ""))),
667
- escape_latex_special_chars(hyphenate_long_strings(e.get("service_provider", "") or ""))
668
- )
669
- for e in encounters
670
- ) + "\n\\bottomrule"
671
- else:
672
- encounters_content = "\\multicolumn{5}{l}{No encounters available}"
673
-
674
- # Use Template for safe insertion
675
- latex_template = Template(r"""
676
- \documentclass[a4paper,12pt]{article}
677
- \usepackage[utf8]{inputenc}
678
- \usepackage[T1]{fontenc}
679
- \usepackage{geometry}
680
- \geometry{margin=1in}
681
- \usepackage{booktabs,longtable,fancyhdr}
682
- \usepackage{array}
683
- \usepackage{microtype}
684
- \microtypesetup{expansion=false} % Disable font expansion to avoid errors
685
- \setlength{\headheight}{14.5pt} % Fix fancyhdr warning
686
- \pagestyle{fancy}
687
- \fancyhf{}
688
- \fancyhead[L]{Patient Report}
689
- \fancyhead[R]{Generated: \today}
690
- \fancyfoot[C]{\thepage}
691
-
692
- \begin{document}
693
-
694
- \begin{center}
695
- \Large\textbf{Patient Medical Report} \\
696
- \vspace{0.2cm}
697
- \textit{Generated on $generated_on}
698
- \end{center}
699
-
700
- \section*{Demographics}
701
- \begin{itemize}
702
- \item \textbf{FHIR ID:} $fhir_id
703
- \item \textbf{Full Name:} $full_name
704
- \item \textbf{Gender:} $gender
705
- \item \textbf{Date of Birth:} $dob
706
- \item \textbf{Age:} $age
707
- \item \textbf{Address:} $address
708
- \item \textbf{Marital Status:} $marital_status
709
- \item \textbf{Language:} $language
710
- \end{itemize}
711
-
712
- \section*{Clinical Notes}
713
- \begin{longtable}{>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{6.5cm}}
714
- \textbf{Date} & \textbf{Type} & \textbf{Text} \\
715
- \endhead
716
- $notes
717
- \end{longtable}
718
-
719
- \section*{Conditions}
720
- \begin{longtable}{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
721
- \textbf{ID} & \textbf{Code} & \textbf{Status} & \textbf{Onset} & \textbf{Verification} \\
722
- \endhead
723
- $conditions
724
- \end{longtable}
725
-
726
- \section*{Medications}
727
- \begin{longtable}{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{4cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
728
- \textbf{ID} & \textbf{Name} & \textbf{Status} & \textbf{Date} & \textbf{Dosage} \\
729
- \endhead
730
- $medications
731
- \end{longtable}
732
-
733
- \section*{Encounters}
734
- \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}}
735
- \textbf{ID} & \textbf{Type} & \textbf{Status} & \textbf{Start} & \textbf{Provider} \\
736
- \endhead
737
- $encounters
738
- \end{longtable}
739
-
740
- \end{document}
741
- """)
742
-
743
- # Set the generated_on date to 05:26 PM CET, May 16, 2025
744
- generated_on = datetime.strptime("2025-05-16 17:26:00+01:00", "%Y-%m-%d %H:%M:%S%z").strftime("%A, %B %d, %Y at %I:%M %p")
745
-
746
- latex_filled = latex_template.substitute(
747
- generated_on=generated_on,
748
- fhir_id=escape_latex_special_chars(hyphenate_long_strings(patient.get("fhir_id", "") or "")),
749
- full_name=escape_latex_special_chars(patient.get("full_name", "") or ""),
750
- gender=escape_latex_special_chars(patient.get("gender", "") or ""),
751
- dob=escape_latex_special_chars(patient.get("date_of_birth", "") or ""),
752
- age=escape_latex_special_chars(str(calculate_age(patient.get("date_of_birth", "")) or "N/A")),
753
- address=escape_latex_special_chars(", ".join(filter(None, [
754
- patient.get("address", ""),
755
- patient.get("city", ""),
756
- patient.get("state", ""),
757
- patient.get("postal_code", ""),
758
- patient.get("country", "")
759
- ]))),
760
- marital_status=escape_latex_special_chars(patient.get("marital_status", "") or ""),
761
- language=escape_latex_special_chars(patient.get("language", "") or ""),
762
- notes=notes_content,
763
- conditions=conditions_content,
764
- medications=medications_content,
765
- encounters=encounters_content
766
- )
767
-
768
- # Compile LaTeX in a temporary directory
769
- with TemporaryDirectory() as tmpdir:
770
- tex_path = os.path.join(tmpdir, "report.tex")
771
- pdf_path = os.path.join(tmpdir, "report.pdf")
772
-
773
- with open(tex_path, "w", encoding="utf-8") as f:
774
- f.write(latex_filled)
775
-
776
- try:
777
- subprocess.run(
778
- ["latexmk", "-pdf", "-interaction=nonstopmode", tex_path],
779
- cwd=tmpdir,
780
- check=True,
781
- capture_output=True,
782
- text=True
783
- )
784
- except subprocess.CalledProcessError as e:
785
- raise HTTPException(
786
- status_code=500,
787
- detail=f"LaTeX compilation failed: stdout={e.stdout}, stderr={e.stderr}"
788
- )
789
-
790
- if not os.path.exists(pdf_path):
791
- raise HTTPException(
792
- status_code=500,
793
- detail="PDF file was not generated"
794
- )
795
-
796
- with open(pdf_path, "rb") as f:
797
- pdf_bytes = f.read()
798
-
799
- response = Response(
800
- content=pdf_bytes,
801
- media_type="application/pdf",
802
- headers={"Content-Disposition": f"attachment; filename=patient_{patient.get('fhir_id', 'unknown')}_report.pdf"}
803
- )
804
- return response
805
-
806
- except HTTPException as http_error:
807
- raise http_error
808
- except Exception as e:
809
- raise HTTPException(
810
- status_code=500,
811
- detail=f"Unexpected error generating PDF: {str(e)}"
812
- )
813
- finally:
814
- # Restore the logger level for other routes
815
- logger.setLevel(logging.INFO)
816
-
817
- @router.post("/signup", status_code=status.HTTP_201_CREATED)
818
- async def signup(data: SignupForm):
819
- logger.info(f"Signup attempt for email: {data.email}")
820
- email = data.email.lower().strip()
821
- existing = await users_collection.find_one({"email": email})
822
- if existing:
823
- logger.warning(f"Signup failed: Email already exists: {email}")
824
- raise HTTPException(
825
- status_code=status.HTTP_409_CONFLICT,
826
- detail="Email already exists"
827
- )
828
-
829
- hashed_pw = hash_password(data.password)
830
- user_doc = {
831
- "email": email,
832
- "full_name": data.full_name.strip(),
833
- "password": hashed_pw,
834
- "role": "patient",
835
- "created_at": datetime.utcnow().isoformat(),
836
- "updated_at": datetime.utcnow().isoformat()
837
- }
838
-
839
- try:
840
- result = await users_collection.insert_one(user_doc)
841
- logger.info(f"User created successfully: {email}")
842
- return {
843
- "status": "success",
844
- "id": str(result.inserted_id),
845
- "email": email
846
- }
847
- except Exception as e:
848
- logger.error(f"Failed to create user {email}: {str(e)}")
849
- raise HTTPException(
850
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
851
- detail=f"Failed to create user: {str(e)}"
852
- )
853
-
854
- @router.post("/admin/doctors", status_code=status.HTTP_201_CREATED)
855
- async def create_doctor(
856
- data: DoctorCreate,
857
- current_user: dict = Depends(get_current_user)
858
- ):
859
- logger.info(f"Doctor creation attempt by {current_user.get('email')}")
860
- if current_user.get('role') != 'admin':
861
- logger.warning(f"Unauthorized doctor creation attempt by {current_user.get('email')}")
862
- raise HTTPException(
863
- status_code=status.HTTP_403_FORBIDDEN,
864
- detail="Only admins can create doctor accounts"
865
- )
866
-
867
- email = data.email.lower().strip()
868
- existing = await users_collection.find_one({"email": email})
869
- if existing:
870
- logger.warning(f"Doctor creation failed: Email already exists: {email}")
871
- raise HTTPException(
872
- status_code=status.HTTP_409_CONFLICT,
873
- detail="Email already exists"
874
- )
875
-
876
- hashed_pw = hash_password(data.password)
877
- doctor_doc = {
878
- "email": email,
879
- "full_name": data.full_name.strip(),
880
- "password": hashed_pw,
881
- "role": "doctor",
882
- "specialty": data.specialty,
883
- "license_number": data.license_number,
884
- "created_at": datetime.utcnow().isoformat(),
885
- "updated_at": datetime.utcnow().isoformat()
886
- }
887
-
888
- try:
889
- result = await users_collection.insert_one(doctor_doc)
890
- logger.info(f"Doctor created successfully: {email}")
891
- return {
892
- "status": "success",
893
- "id": str(result.inserted_id),
894
- "email": email
895
- }
896
- except Exception as e:
897
- logger.error(f"Failed to create doctor {email}: {str(e)}")
898
- raise HTTPException(
899
- status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
900
- detail=f"Failed to create doctor: {str(e)}"
901
- )
902
-
903
- @router.post("/login", response_model=TokenResponse)
904
- async def login(form_data: OAuth2PasswordRequestForm = Depends()):
905
- logger.info(f"Login attempt for email: {form_data.username}")
906
- user = await users_collection.find_one({"email": form_data.username.lower()})
907
- if not user or not verify_password(form_data.password, user["password"]):
908
- logger.warning(f"Login failed for {form_data.username}: Invalid credentials")
909
- raise HTTPException(
910
- status_code=status.HTTP_401_UNAUTHORIZED,
911
- detail="Invalid credentials",
912
- headers={"WWW-Authenticate": "Bearer"},
913
- )
914
-
915
- access_token = create_access_token(data={"sub": user["email"]})
916
- logger.info(f"Successful login for {form_data.username}")
917
- return {
918
- "access_token": access_token,
919
- "token_type": "bearer",
920
- "role": user.get("role", "patient")
921
- }
922
-
923
- @router.get("/me")
924
- async def get_me(current_user: dict = Depends(get_current_user)):
925
- logger.info(f"Fetching user profile for {current_user['email']}")
926
- user = await users_collection.find_one({"email": current_user["email"]})
927
- if not user:
928
- logger.warning(f"User not found: {current_user['email']}")
929
- raise HTTPException(
930
- status_code=status.HTTP_404_NOT_FOUND,
931
- detail="User not found"
932
- )
933
-
934
- response = {
935
- "id": str(user["_id"]),
936
- "email": user["email"],
937
- "full_name": user.get("full_name", ""),
938
- "role": user.get("role", "patient"),
939
- "specialty": user.get("specialty"),
940
- "created_at": user.get("created_at"),
941
- "updated_at": user.get("updated_at")
942
- }
943
- logger.info(f"User profile retrieved for {current_user['email']}")
944
- return response
 
1
+ from fastapi import APIRouter
2
+ from .routes import auth, patients, pdf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  router = APIRouter()
5
 
6
+ # Include sub-routers
7
+ router.include_router(auth.router, prefix="/auth", tags=["auth"])
8
+ router.include_router(patients.router, prefix="/ehr", tags=["patients"])
9
+ router.include_router(pdf.router, prefix="/ehr/patients", tags=["pdf"])