Spaces:
Sleeping
Sleeping
Initial CPS-API deployment with TxAgent integration
Browse files- __init__.py +33 -1
- api/routes/messaging.py +8 -2
- api/routes/patients.py +8 -1
- deployment/__init__.py +33 -0
- deployment/routes.py +1 -0
- deployment/routes/__init__.py +0 -0
- deployment/routes/appointments.py +904 -0
- deployment/routes/auth.py +462 -0
- deployment/routes/fhir_integration.py +375 -0
- deployment/routes/messaging.py +903 -0
- deployment/routes/patients.py +1153 -0
- deployment/routes/pdf.py +268 -0
- deployment/routes/txagent.py +170 -0
- deployment/services/fhir_integration.py +243 -0
- deployment/services/txagent_service.py +118 -0
- deployment/utils/fhir_client.py +323 -0
- routes.py +1 -0
- routes/__init__.py +0 -0
- routes/appointments.py +904 -0
- routes/auth.py +462 -0
- routes/fhir_integration.py +375 -0
- routes/messaging.py +903 -0
- routes/patients.py +1153 -0
- routes/pdf.py +268 -0
- routes/txagent.py +170 -0
- services/fhir_integration.py +243 -0
- services/txagent_service.py +118 -0
- utils/fhir_client.py +323 -0
__init__.py
CHANGED
|
@@ -1 +1,33 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
# Configure logging
|
| 5 |
+
logging.basicConfig(
|
| 6 |
+
level=logging.INFO,
|
| 7 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 8 |
+
)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def get_router():
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# Import sub-modules from the routes directory
|
| 15 |
+
from .routes.auth import auth
|
| 16 |
+
from .routes.patients import patients
|
| 17 |
+
from .routes.pdf import pdf
|
| 18 |
+
from .routes.appointments import router as appointments
|
| 19 |
+
from .routes.messaging import router as messaging
|
| 20 |
+
from .routes.txagent import router as txagent
|
| 21 |
+
|
| 22 |
+
# Include sub-routers with correct prefixes
|
| 23 |
+
router.include_router(patients, tags=["patients"]) # Remove prefix since routes already have /patients
|
| 24 |
+
router.include_router(auth, prefix="/auth", tags=["auth"])
|
| 25 |
+
router.include_router(pdf, prefix="/patients", tags=["pdf"]) # Keep prefix for PDF routes
|
| 26 |
+
router.include_router(appointments, tags=["appointments"])
|
| 27 |
+
router.include_router(messaging, tags=["messaging"])
|
| 28 |
+
router.include_router(txagent, tags=["txagent"])
|
| 29 |
+
|
| 30 |
+
return router
|
| 31 |
+
|
| 32 |
+
# Export the router
|
| 33 |
+
api_router = get_router()
|
api/routes/messaging.py
CHANGED
|
@@ -795,8 +795,14 @@ async def upload_file(
|
|
| 795 |
)
|
| 796 |
|
| 797 |
# Create uploads directory if it doesn't exist
|
| 798 |
-
|
| 799 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 800 |
|
| 801 |
# Create category subdirectory
|
| 802 |
category_dir = upload_dir / file_category
|
|
|
|
| 795 |
)
|
| 796 |
|
| 797 |
# Create uploads directory if it doesn't exist
|
| 798 |
+
try:
|
| 799 |
+
upload_dir = Path("uploads")
|
| 800 |
+
upload_dir.mkdir(exist_ok=True)
|
| 801 |
+
except PermissionError:
|
| 802 |
+
# In containerized environments, use temp directory
|
| 803 |
+
import tempfile
|
| 804 |
+
upload_dir = Path(tempfile.gettempdir()) / "uploads"
|
| 805 |
+
upload_dir.mkdir(exist_ok=True)
|
| 806 |
|
| 807 |
# Create category subdirectory
|
| 808 |
category_dir = upload_dir / file_category
|
api/routes/patients.py
CHANGED
|
@@ -35,7 +35,14 @@ router = APIRouter()
|
|
| 35 |
# Configuration
|
| 36 |
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 37 |
SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
# Pydantic models for update validation
|
| 41 |
class ConditionUpdate(BaseModel):
|
|
|
|
| 35 |
# Configuration
|
| 36 |
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 37 |
SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
|
| 38 |
+
try:
|
| 39 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 40 |
+
except PermissionError:
|
| 41 |
+
# In containerized environments, we might not have write permissions
|
| 42 |
+
# Use a temporary directory instead
|
| 43 |
+
import tempfile
|
| 44 |
+
SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
|
| 45 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 46 |
|
| 47 |
# Pydantic models for update validation
|
| 48 |
class ConditionUpdate(BaseModel):
|
deployment/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
import logging
|
| 3 |
+
|
| 4 |
+
# Configure logging
|
| 5 |
+
logging.basicConfig(
|
| 6 |
+
level=logging.INFO,
|
| 7 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 8 |
+
)
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
def get_router():
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
# Import sub-modules from the routes directory
|
| 15 |
+
from .routes.auth import auth
|
| 16 |
+
from .routes.patients import patients
|
| 17 |
+
from .routes.pdf import pdf
|
| 18 |
+
from .routes.appointments import router as appointments
|
| 19 |
+
from .routes.messaging import router as messaging
|
| 20 |
+
from .routes.txagent import router as txagent
|
| 21 |
+
|
| 22 |
+
# Include sub-routers with correct prefixes
|
| 23 |
+
router.include_router(patients, tags=["patients"]) # Remove prefix since routes already have /patients
|
| 24 |
+
router.include_router(auth, prefix="/auth", tags=["auth"])
|
| 25 |
+
router.include_router(pdf, prefix="/patients", tags=["pdf"]) # Keep prefix for PDF routes
|
| 26 |
+
router.include_router(appointments, tags=["appointments"])
|
| 27 |
+
router.include_router(messaging, tags=["messaging"])
|
| 28 |
+
router.include_router(txagent, tags=["txagent"])
|
| 29 |
+
|
| 30 |
+
return router
|
| 31 |
+
|
| 32 |
+
# Export the router
|
| 33 |
+
api_router = get_router()
|
deployment/routes.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
#
|
deployment/routes/__init__.py
ADDED
|
File without changes
|
deployment/routes/appointments.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import date, time, datetime, timedelta
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 6 |
+
|
| 7 |
+
from core.security import get_current_user
|
| 8 |
+
from db.mongo import db, patients_collection
|
| 9 |
+
from models.schemas import (
|
| 10 |
+
AppointmentCreate, AppointmentUpdate, AppointmentResponse, AppointmentListResponse,
|
| 11 |
+
AppointmentStatus, AppointmentType, DoctorAvailabilityCreate, DoctorAvailabilityUpdate,
|
| 12 |
+
DoctorAvailabilityResponse, AvailableSlotsResponse, AppointmentSlot, DoctorListResponse
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/appointments", tags=["appointments"])
|
| 16 |
+
|
| 17 |
+
# --- HELPER FUNCTIONS ---
|
| 18 |
+
def is_valid_object_id(id_str: str) -> bool:
|
| 19 |
+
try:
|
| 20 |
+
ObjectId(id_str)
|
| 21 |
+
return True
|
| 22 |
+
except:
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
def get_weekday_from_date(appointment_date: date) -> int:
|
| 26 |
+
"""Convert date to weekday (0=Monday, 6=Sunday)"""
|
| 27 |
+
return appointment_date.weekday()
|
| 28 |
+
|
| 29 |
+
def generate_time_slots(start_time: time, end_time: time, duration: int = 30) -> List[time]:
|
| 30 |
+
"""Generate time slots between start and end time"""
|
| 31 |
+
slots = []
|
| 32 |
+
current_time = datetime.combine(date.today(), start_time)
|
| 33 |
+
end_datetime = datetime.combine(date.today(), end_time)
|
| 34 |
+
|
| 35 |
+
while current_time < end_datetime:
|
| 36 |
+
slots.append(current_time.time())
|
| 37 |
+
current_time += timedelta(minutes=duration)
|
| 38 |
+
|
| 39 |
+
return slots
|
| 40 |
+
|
| 41 |
+
# --- PATIENT ASSIGNMENT ENDPOINT ---
|
| 42 |
+
|
| 43 |
+
@router.post("/assign-patient", status_code=status.HTTP_200_OK)
|
| 44 |
+
async def assign_patient_to_doctor(
|
| 45 |
+
patient_id: str,
|
| 46 |
+
doctor_id: str,
|
| 47 |
+
current_user: dict = Depends(get_current_user),
|
| 48 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 49 |
+
):
|
| 50 |
+
"""Manually assign a patient to a doctor"""
|
| 51 |
+
# Only doctors and admins can assign patients
|
| 52 |
+
if not (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or
|
| 53 |
+
('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 56 |
+
detail="Only doctors and admins can assign patients"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Validate ObjectIds
|
| 60 |
+
if not is_valid_object_id(patient_id) or not is_valid_object_id(doctor_id):
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 63 |
+
detail="Invalid patient_id or doctor_id"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Check if doctor exists and is a doctor
|
| 67 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 68 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 71 |
+
detail="Doctor not found"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Check if patient exists
|
| 75 |
+
patient = await db_client.users.find_one({"_id": ObjectId(patient_id)})
|
| 76 |
+
if not patient:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 79 |
+
detail="Patient not found"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
# Check if patient already exists in patients collection
|
| 84 |
+
existing_patient = await patients_collection.find_one({
|
| 85 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
if existing_patient:
|
| 89 |
+
# Update existing patient to assign to this doctor
|
| 90 |
+
await patients_collection.update_one(
|
| 91 |
+
{"_id": existing_patient["_id"]},
|
| 92 |
+
{
|
| 93 |
+
"$set": {
|
| 94 |
+
"assigned_doctor_id": str(doctor_id), # Convert to string
|
| 95 |
+
"updated_at": datetime.now()
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
message = f"Patient {patient.get('full_name', 'Unknown')} reassigned to doctor {doctor.get('full_name', 'Unknown')}"
|
| 100 |
+
else:
|
| 101 |
+
# Create new patient record in patients collection
|
| 102 |
+
patient_doc = {
|
| 103 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 104 |
+
"full_name": patient.get("full_name", ""),
|
| 105 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 106 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 107 |
+
"address": patient.get("address"),
|
| 108 |
+
"city": patient.get("city"),
|
| 109 |
+
"state": patient.get("state"),
|
| 110 |
+
"postal_code": patient.get("postal_code"),
|
| 111 |
+
"country": patient.get("country"),
|
| 112 |
+
"national_id": patient.get("national_id"),
|
| 113 |
+
"blood_type": patient.get("blood_type"),
|
| 114 |
+
"allergies": patient.get("allergies", []),
|
| 115 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 116 |
+
"medications": patient.get("medications", []),
|
| 117 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 118 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 119 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 120 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 121 |
+
"notes": patient.get("notes", []),
|
| 122 |
+
"source": "manual", # Manually assigned
|
| 123 |
+
"status": "active",
|
| 124 |
+
"assigned_doctor_id": str(doctor_id), # Convert to string
|
| 125 |
+
"registration_date": datetime.now(),
|
| 126 |
+
"created_at": datetime.now(),
|
| 127 |
+
"updated_at": datetime.now()
|
| 128 |
+
}
|
| 129 |
+
await patients_collection.insert_one(patient_doc)
|
| 130 |
+
message = f"Patient {patient.get('full_name', 'Unknown')} assigned to doctor {doctor.get('full_name', 'Unknown')}"
|
| 131 |
+
|
| 132 |
+
print(f"✅ {message}")
|
| 133 |
+
return {"message": message, "success": True}
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
print(f"❌ Error assigning patient to doctor: {str(e)}")
|
| 137 |
+
raise HTTPException(
|
| 138 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 139 |
+
detail=f"Failed to assign patient to doctor: {str(e)}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# --- DOCTOR LIST ENDPOINT (MUST BE BEFORE PARAMETERIZED ROUTES) ---
|
| 143 |
+
|
| 144 |
+
@router.get("/doctors", response_model=List[DoctorListResponse])
|
| 145 |
+
async def get_doctors(
|
| 146 |
+
specialty: Optional[str] = Query(None),
|
| 147 |
+
current_user: dict = Depends(get_current_user),
|
| 148 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 149 |
+
):
|
| 150 |
+
"""Get list of available doctors"""
|
| 151 |
+
# Build filter - handle both "role" (singular) and "roles" (array) fields
|
| 152 |
+
filter_query = {
|
| 153 |
+
"$or": [
|
| 154 |
+
{"roles": {"$in": ["doctor"]}},
|
| 155 |
+
{"role": "doctor"}
|
| 156 |
+
]
|
| 157 |
+
}
|
| 158 |
+
if specialty:
|
| 159 |
+
filter_query["$and"] = [
|
| 160 |
+
{"$or": [{"roles": {"$in": ["doctor"]}}, {"role": "doctor"}]},
|
| 161 |
+
{"specialty": {"$regex": specialty, "$options": "i"}}
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
# Get doctors
|
| 165 |
+
cursor = db_client.users.find(filter_query)
|
| 166 |
+
doctors = await cursor.to_list(length=None)
|
| 167 |
+
|
| 168 |
+
return [
|
| 169 |
+
DoctorListResponse(
|
| 170 |
+
id=str(doctor["_id"]),
|
| 171 |
+
full_name=doctor['full_name'],
|
| 172 |
+
specialty=doctor.get('specialty', ''),
|
| 173 |
+
license_number=doctor.get('license_number', ''),
|
| 174 |
+
email=doctor['email'],
|
| 175 |
+
phone=doctor.get('phone')
|
| 176 |
+
)
|
| 177 |
+
for doctor in doctors
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
# --- DOCTOR AVAILABILITY ENDPOINTS ---
|
| 181 |
+
|
| 182 |
+
@router.post("/availability", response_model=DoctorAvailabilityResponse, status_code=status.HTTP_201_CREATED)
|
| 183 |
+
async def create_doctor_availability(
|
| 184 |
+
availability_data: DoctorAvailabilityCreate,
|
| 185 |
+
current_user: dict = Depends(get_current_user),
|
| 186 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 187 |
+
):
|
| 188 |
+
"""Create doctor availability"""
|
| 189 |
+
if (current_user.get('role') != 'doctor' and 'doctor' not in current_user.get('roles', [])) and 'admin' not in current_user.get('roles', []):
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 192 |
+
detail="Only doctors can set their availability"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Check if doctor exists
|
| 196 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(availability_data.doctor_id)})
|
| 197 |
+
if not doctor:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 200 |
+
detail="Doctor not found"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Check if doctor is setting their own availability or admin is setting it
|
| 204 |
+
if (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])) and availability_data.doctor_id != current_user["_id"]:
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 207 |
+
detail="You can only set your own availability"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Check if availability already exists for this day
|
| 211 |
+
existing = await db_client.doctor_availability.find_one({
|
| 212 |
+
"doctor_id": ObjectId(availability_data.doctor_id),
|
| 213 |
+
"day_of_week": availability_data.day_of_week
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
if existing:
|
| 217 |
+
raise HTTPException(
|
| 218 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 219 |
+
detail="Availability already exists for this day"
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Create availability
|
| 223 |
+
availability_doc = {
|
| 224 |
+
"doctor_id": ObjectId(availability_data.doctor_id),
|
| 225 |
+
"day_of_week": availability_data.day_of_week,
|
| 226 |
+
"start_time": availability_data.start_time,
|
| 227 |
+
"end_time": availability_data.end_time,
|
| 228 |
+
"is_available": availability_data.is_available
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
result = await db_client.doctor_availability.insert_one(availability_doc)
|
| 232 |
+
availability_doc["_id"] = result.inserted_id
|
| 233 |
+
|
| 234 |
+
return DoctorAvailabilityResponse(
|
| 235 |
+
id=str(availability_doc["_id"]),
|
| 236 |
+
doctor_id=availability_data.doctor_id,
|
| 237 |
+
doctor_name=doctor['full_name'],
|
| 238 |
+
day_of_week=availability_doc["day_of_week"],
|
| 239 |
+
start_time=availability_doc["start_time"],
|
| 240 |
+
end_time=availability_doc["end_time"],
|
| 241 |
+
is_available=availability_doc["is_available"]
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
@router.get("/availability/{doctor_id}", response_model=List[DoctorAvailabilityResponse])
|
| 245 |
+
async def get_doctor_availability(
|
| 246 |
+
doctor_id: str,
|
| 247 |
+
current_user: dict = Depends(get_current_user),
|
| 248 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 249 |
+
):
|
| 250 |
+
"""Get doctor availability"""
|
| 251 |
+
if not is_valid_object_id(doctor_id):
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 254 |
+
detail="Invalid doctor ID"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Check if doctor exists
|
| 258 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 259 |
+
if not doctor:
|
| 260 |
+
raise HTTPException(
|
| 261 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 262 |
+
detail="Doctor not found"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Get availability
|
| 266 |
+
cursor = db_client.doctor_availability.find({"doctor_id": ObjectId(doctor_id)})
|
| 267 |
+
availabilities = await cursor.to_list(length=None)
|
| 268 |
+
|
| 269 |
+
return [
|
| 270 |
+
DoctorAvailabilityResponse(
|
| 271 |
+
id=str(avail["_id"]),
|
| 272 |
+
doctor_id=doctor_id,
|
| 273 |
+
doctor_name=doctor['full_name'],
|
| 274 |
+
day_of_week=avail["day_of_week"],
|
| 275 |
+
start_time=avail["start_time"],
|
| 276 |
+
end_time=avail["end_time"],
|
| 277 |
+
is_available=avail["is_available"]
|
| 278 |
+
)
|
| 279 |
+
for avail in availabilities
|
| 280 |
+
]
|
| 281 |
+
|
| 282 |
+
# --- AVAILABLE SLOTS ENDPOINTS ---
|
| 283 |
+
|
| 284 |
+
@router.get("/slots/{doctor_id}/{date}", response_model=AvailableSlotsResponse)
|
| 285 |
+
async def get_available_slots(
|
| 286 |
+
doctor_id: str,
|
| 287 |
+
date: date,
|
| 288 |
+
current_user: dict = Depends(get_current_user),
|
| 289 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 290 |
+
):
|
| 291 |
+
"""Get available appointment slots for a doctor on a specific date"""
|
| 292 |
+
if not is_valid_object_id(doctor_id):
|
| 293 |
+
raise HTTPException(
|
| 294 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 295 |
+
detail="Invalid doctor ID"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Check if doctor exists
|
| 299 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 300 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 301 |
+
raise HTTPException(
|
| 302 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 303 |
+
detail="Doctor not found"
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# Get doctor availability for this day
|
| 307 |
+
weekday = get_weekday_from_date(date)
|
| 308 |
+
availability = await db_client.doctor_availability.find_one({
|
| 309 |
+
"doctor_id": ObjectId(doctor_id),
|
| 310 |
+
"day_of_week": weekday,
|
| 311 |
+
"is_available": True
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
if not availability:
|
| 315 |
+
return AvailableSlotsResponse(
|
| 316 |
+
doctor_id=doctor_id,
|
| 317 |
+
doctor_name=doctor['full_name'],
|
| 318 |
+
specialty=doctor.get('specialty', ''),
|
| 319 |
+
date=date,
|
| 320 |
+
slots=[]
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Generate time slots
|
| 324 |
+
time_slots = generate_time_slots(availability["start_time"], availability["end_time"])
|
| 325 |
+
|
| 326 |
+
# Get existing appointments for this date
|
| 327 |
+
existing_appointments = await db_client.appointments.find({
|
| 328 |
+
"doctor_id": ObjectId(doctor_id),
|
| 329 |
+
"date": date,
|
| 330 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 331 |
+
}).to_list(length=None)
|
| 332 |
+
|
| 333 |
+
booked_times = {apt["time"] for apt in existing_appointments}
|
| 334 |
+
|
| 335 |
+
# Create slot responses
|
| 336 |
+
slots = []
|
| 337 |
+
for slot_time in time_slots:
|
| 338 |
+
is_available = slot_time not in booked_times
|
| 339 |
+
appointment_id = None
|
| 340 |
+
if not is_available:
|
| 341 |
+
# Find the appointment ID for this slot
|
| 342 |
+
appointment = next((apt for apt in existing_appointments if apt["time"] == slot_time), None)
|
| 343 |
+
appointment_id = str(appointment["_id"]) if appointment else None
|
| 344 |
+
|
| 345 |
+
slots.append(AppointmentSlot(
|
| 346 |
+
date=date,
|
| 347 |
+
time=slot_time,
|
| 348 |
+
is_available=is_available,
|
| 349 |
+
appointment_id=appointment_id
|
| 350 |
+
))
|
| 351 |
+
|
| 352 |
+
return AvailableSlotsResponse(
|
| 353 |
+
doctor_id=doctor_id,
|
| 354 |
+
doctor_name=doctor['full_name'],
|
| 355 |
+
specialty=doctor.get('specialty', ''),
|
| 356 |
+
date=date,
|
| 357 |
+
slots=slots
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
# --- APPOINTMENT ENDPOINTS ---
|
| 361 |
+
|
| 362 |
+
@router.post("/", response_model=AppointmentResponse, status_code=status.HTTP_201_CREATED)
|
| 363 |
+
async def create_appointment(
|
| 364 |
+
appointment_data: AppointmentCreate,
|
| 365 |
+
current_user: dict = Depends(get_current_user),
|
| 366 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 367 |
+
):
|
| 368 |
+
"""Create a new appointment"""
|
| 369 |
+
print(f"🔍 Creating appointment with data: {appointment_data}")
|
| 370 |
+
print(f"🔍 Current user: {current_user}")
|
| 371 |
+
# Check if user is a patient
|
| 372 |
+
if 'patient' not in current_user.get('roles', []) and current_user.get('role') != 'patient':
|
| 373 |
+
raise HTTPException(
|
| 374 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 375 |
+
detail="Only patients can create appointments"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# Validate ObjectIds
|
| 379 |
+
if not is_valid_object_id(appointment_data.patient_id) or not is_valid_object_id(appointment_data.doctor_id):
|
| 380 |
+
raise HTTPException(
|
| 381 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 382 |
+
detail="Invalid patient_id or doctor_id"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Check if patient exists and matches current user
|
| 386 |
+
patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
|
| 387 |
+
if not patient:
|
| 388 |
+
raise HTTPException(
|
| 389 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 390 |
+
detail="Patient not found"
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
if patient['email'] != current_user['email']:
|
| 394 |
+
raise HTTPException(
|
| 395 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 396 |
+
detail="You can only create appointments for yourself"
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Check if doctor exists and is a doctor
|
| 400 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(appointment_data.doctor_id)})
|
| 401 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 402 |
+
raise HTTPException(
|
| 403 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 404 |
+
detail="Doctor not found"
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
# Convert string date and time to proper objects
|
| 408 |
+
try:
|
| 409 |
+
appointment_date = datetime.strptime(appointment_data.date, '%Y-%m-%d').date()
|
| 410 |
+
appointment_time = datetime.strptime(appointment_data.time, '%H:%M:%S').time()
|
| 411 |
+
# Convert date to datetime for MongoDB compatibility
|
| 412 |
+
appointment_datetime = datetime.combine(appointment_date, appointment_time)
|
| 413 |
+
except ValueError:
|
| 414 |
+
raise HTTPException(
|
| 415 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 416 |
+
detail="Invalid date or time format. Use YYYY-MM-DD for date and HH:MM:SS for time"
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
# Check if appointment date is in the future
|
| 420 |
+
if appointment_datetime <= datetime.now():
|
| 421 |
+
raise HTTPException(
|
| 422 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 423 |
+
detail="Appointment must be scheduled for a future date and time"
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Check if slot is available
|
| 427 |
+
existing_appointment = await db_client.appointments.find_one({
|
| 428 |
+
"doctor_id": ObjectId(appointment_data.doctor_id),
|
| 429 |
+
"date": appointment_data.date,
|
| 430 |
+
"time": appointment_data.time,
|
| 431 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 432 |
+
})
|
| 433 |
+
|
| 434 |
+
if existing_appointment:
|
| 435 |
+
raise HTTPException(
|
| 436 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 437 |
+
detail="This time slot is already booked"
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# Create appointment
|
| 441 |
+
appointment_doc = {
|
| 442 |
+
"patient_id": ObjectId(appointment_data.patient_id),
|
| 443 |
+
"doctor_id": ObjectId(appointment_data.doctor_id),
|
| 444 |
+
"date": appointment_data.date, # Store as string
|
| 445 |
+
"time": appointment_data.time, # Store as string
|
| 446 |
+
"datetime": appointment_datetime, # Store full datetime for easier querying
|
| 447 |
+
"type": appointment_data.type,
|
| 448 |
+
"status": AppointmentStatus.PENDING,
|
| 449 |
+
"reason": appointment_data.reason,
|
| 450 |
+
"notes": appointment_data.notes,
|
| 451 |
+
"duration": appointment_data.duration,
|
| 452 |
+
"created_at": datetime.now(),
|
| 453 |
+
"updated_at": datetime.now()
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
result = await db_client.appointments.insert_one(appointment_doc)
|
| 457 |
+
appointment_doc["_id"] = result.inserted_id
|
| 458 |
+
|
| 459 |
+
# If appointment is created with confirmed status, assign patient to doctor
|
| 460 |
+
if appointment_data.status == "confirmed":
|
| 461 |
+
try:
|
| 462 |
+
# Get patient details from users collection
|
| 463 |
+
patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
|
| 464 |
+
if patient:
|
| 465 |
+
# Check if patient already exists in patients collection
|
| 466 |
+
existing_patient = await patients_collection.find_one({
|
| 467 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 468 |
+
})
|
| 469 |
+
|
| 470 |
+
if existing_patient:
|
| 471 |
+
# Update existing patient to assign to this doctor
|
| 472 |
+
await patients_collection.update_one(
|
| 473 |
+
{"_id": existing_patient["_id"]},
|
| 474 |
+
{
|
| 475 |
+
"$set": {
|
| 476 |
+
"assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
|
| 477 |
+
"updated_at": datetime.now()
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
)
|
| 481 |
+
else:
|
| 482 |
+
# Create new patient record in patients collection
|
| 483 |
+
patient_doc = {
|
| 484 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 485 |
+
"full_name": patient.get("full_name", ""),
|
| 486 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 487 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 488 |
+
"address": patient.get("address"),
|
| 489 |
+
"city": patient.get("city"),
|
| 490 |
+
"state": patient.get("state"),
|
| 491 |
+
"postal_code": patient.get("postal_code"),
|
| 492 |
+
"country": patient.get("country"),
|
| 493 |
+
"national_id": patient.get("national_id"),
|
| 494 |
+
"blood_type": patient.get("blood_type"),
|
| 495 |
+
"allergies": patient.get("allergies", []),
|
| 496 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 497 |
+
"medications": patient.get("medications", []),
|
| 498 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 499 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 500 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 501 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 502 |
+
"notes": patient.get("notes", []),
|
| 503 |
+
"source": "direct", # Patient came through appointment booking
|
| 504 |
+
"status": "active",
|
| 505 |
+
"assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
|
| 506 |
+
"registration_date": datetime.now(),
|
| 507 |
+
"created_at": datetime.now(),
|
| 508 |
+
"updated_at": datetime.now()
|
| 509 |
+
}
|
| 510 |
+
await patients_collection.insert_one(patient_doc)
|
| 511 |
+
|
| 512 |
+
print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment_data.doctor_id}")
|
| 513 |
+
except Exception as e:
|
| 514 |
+
print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
|
| 515 |
+
# Don't fail the appointment creation if patient assignment fails
|
| 516 |
+
|
| 517 |
+
# Return appointment with patient and doctor names
|
| 518 |
+
return AppointmentResponse(
|
| 519 |
+
id=str(appointment_doc["_id"]),
|
| 520 |
+
patient_id=appointment_data.patient_id,
|
| 521 |
+
doctor_id=appointment_data.doctor_id,
|
| 522 |
+
patient_name=patient['full_name'],
|
| 523 |
+
doctor_name=doctor['full_name'],
|
| 524 |
+
date=appointment_date, # Convert back to date object
|
| 525 |
+
time=appointment_time, # Convert back to time object
|
| 526 |
+
type=appointment_doc["type"],
|
| 527 |
+
status=appointment_doc["status"],
|
| 528 |
+
reason=appointment_doc["reason"],
|
| 529 |
+
notes=appointment_doc["notes"],
|
| 530 |
+
duration=appointment_doc["duration"],
|
| 531 |
+
created_at=appointment_doc["created_at"],
|
| 532 |
+
updated_at=appointment_doc["updated_at"]
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
@router.get("/", response_model=AppointmentListResponse)
|
| 536 |
+
async def get_appointments(
|
| 537 |
+
page: int = Query(1, ge=1),
|
| 538 |
+
limit: int = Query(10, ge=1, le=100),
|
| 539 |
+
status_filter: Optional[AppointmentStatus] = Query(None),
|
| 540 |
+
date_from: Optional[date] = Query(None),
|
| 541 |
+
date_to: Optional[date] = Query(None),
|
| 542 |
+
current_user: dict = Depends(get_current_user),
|
| 543 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 544 |
+
):
|
| 545 |
+
"""Get appointments based on user role"""
|
| 546 |
+
skip = (page - 1) * limit
|
| 547 |
+
|
| 548 |
+
# Build filter based on user role
|
| 549 |
+
if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 550 |
+
# Patients see their own appointments
|
| 551 |
+
filter_query = {"patient_id": ObjectId(current_user["_id"])}
|
| 552 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 553 |
+
# Doctors see appointments assigned to them
|
| 554 |
+
filter_query = {"doctor_id": ObjectId(current_user["_id"])}
|
| 555 |
+
elif 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 556 |
+
# Admins see all appointments
|
| 557 |
+
filter_query = {}
|
| 558 |
+
else:
|
| 559 |
+
raise HTTPException(
|
| 560 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 561 |
+
detail="Insufficient permissions"
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# Add status filter
|
| 565 |
+
if status_filter:
|
| 566 |
+
filter_query["status"] = status_filter
|
| 567 |
+
|
| 568 |
+
# Add date range filter
|
| 569 |
+
if date_from or date_to:
|
| 570 |
+
date_filter = {}
|
| 571 |
+
if date_from:
|
| 572 |
+
date_filter["$gte"] = date_from
|
| 573 |
+
if date_to:
|
| 574 |
+
date_filter["$lte"] = date_to
|
| 575 |
+
filter_query["date"] = date_filter
|
| 576 |
+
|
| 577 |
+
# Get appointments
|
| 578 |
+
cursor = db_client.appointments.find(filter_query).skip(skip).limit(limit).sort("date", -1)
|
| 579 |
+
appointments = await cursor.to_list(length=limit)
|
| 580 |
+
|
| 581 |
+
print(f"🔍 Found {len(appointments)} appointments")
|
| 582 |
+
for i, apt in enumerate(appointments):
|
| 583 |
+
print(f"🔍 Appointment {i}: {apt}")
|
| 584 |
+
|
| 585 |
+
# Get total count
|
| 586 |
+
total = await db_client.appointments.count_documents(filter_query)
|
| 587 |
+
|
| 588 |
+
# Get patient and doctor names
|
| 589 |
+
appointment_responses = []
|
| 590 |
+
for apt in appointments:
|
| 591 |
+
patient = await db_client.users.find_one({"_id": apt["patient_id"]})
|
| 592 |
+
doctor = await db_client.users.find_one({"_id": apt["doctor_id"]})
|
| 593 |
+
|
| 594 |
+
# Convert string date/time back to objects for response
|
| 595 |
+
apt_date = datetime.strptime(apt["date"], '%Y-%m-%d').date() if isinstance(apt["date"], str) else apt["date"]
|
| 596 |
+
apt_time = datetime.strptime(apt["time"], '%H:%M:%S').time() if isinstance(apt["time"], str) else apt["time"]
|
| 597 |
+
|
| 598 |
+
appointment_responses.append(AppointmentResponse(
|
| 599 |
+
id=str(apt["_id"]),
|
| 600 |
+
patient_id=str(apt["patient_id"]),
|
| 601 |
+
doctor_id=str(apt["doctor_id"]),
|
| 602 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 603 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 604 |
+
date=apt_date,
|
| 605 |
+
time=apt_time,
|
| 606 |
+
type=apt.get("type", "consultation"), # Default to consultation if missing
|
| 607 |
+
status=apt.get("status", "pending"), # Default to pending if missing
|
| 608 |
+
reason=apt.get("reason"),
|
| 609 |
+
notes=apt.get("notes"),
|
| 610 |
+
duration=apt.get("duration", 30),
|
| 611 |
+
created_at=apt.get("created_at", datetime.now()),
|
| 612 |
+
updated_at=apt.get("updated_at", datetime.now())
|
| 613 |
+
))
|
| 614 |
+
|
| 615 |
+
return AppointmentListResponse(
|
| 616 |
+
appointments=appointment_responses,
|
| 617 |
+
total=total,
|
| 618 |
+
page=page,
|
| 619 |
+
limit=limit
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
@router.get("/{appointment_id}", response_model=AppointmentResponse)
|
| 623 |
+
async def get_appointment(
|
| 624 |
+
appointment_id: str,
|
| 625 |
+
current_user: dict = Depends(get_current_user),
|
| 626 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 627 |
+
):
|
| 628 |
+
"""Get a specific appointment"""
|
| 629 |
+
if not is_valid_object_id(appointment_id):
|
| 630 |
+
raise HTTPException(
|
| 631 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 632 |
+
detail="Invalid appointment ID"
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 636 |
+
if not appointment:
|
| 637 |
+
raise HTTPException(
|
| 638 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 639 |
+
detail="Appointment not found"
|
| 640 |
+
)
|
| 641 |
+
|
| 642 |
+
# Check permissions
|
| 643 |
+
if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 644 |
+
if appointment["patient_id"] != ObjectId(current_user["_id"]):
|
| 645 |
+
raise HTTPException(
|
| 646 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 647 |
+
detail="You can only view your own appointments"
|
| 648 |
+
)
|
| 649 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 650 |
+
if appointment["doctor_id"] != ObjectId(current_user["_id"]):
|
| 651 |
+
raise HTTPException(
|
| 652 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 653 |
+
detail="You can only view appointments assigned to you"
|
| 654 |
+
)
|
| 655 |
+
elif (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])):
|
| 656 |
+
raise HTTPException(
|
| 657 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 658 |
+
detail="Insufficient permissions"
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
# Get patient and doctor names
|
| 662 |
+
patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
|
| 663 |
+
doctor = await db_client.users.find_one({"_id": appointment["doctor_id"]})
|
| 664 |
+
|
| 665 |
+
# Convert string date/time back to objects for response
|
| 666 |
+
apt_date = datetime.strptime(appointment["date"], '%Y-%m-%d').date() if isinstance(appointment["date"], str) else appointment["date"]
|
| 667 |
+
apt_time = datetime.strptime(appointment["time"], '%H:%M:%S').time() if isinstance(appointment["time"], str) else appointment["time"]
|
| 668 |
+
|
| 669 |
+
return AppointmentResponse(
|
| 670 |
+
id=str(appointment["_id"]),
|
| 671 |
+
patient_id=str(appointment["patient_id"]),
|
| 672 |
+
doctor_id=str(appointment["doctor_id"]),
|
| 673 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 674 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 675 |
+
date=apt_date,
|
| 676 |
+
time=apt_time,
|
| 677 |
+
type=appointment.get("type", "consultation"), # Default to consultation if missing
|
| 678 |
+
status=appointment.get("status", "pending"), # Default to pending if missing
|
| 679 |
+
reason=appointment.get("reason"),
|
| 680 |
+
notes=appointment.get("notes"),
|
| 681 |
+
duration=appointment.get("duration", 30),
|
| 682 |
+
created_at=appointment.get("created_at", datetime.now()),
|
| 683 |
+
updated_at=appointment.get("updated_at", datetime.now())
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
@router.put("/{appointment_id}", response_model=AppointmentResponse)
|
| 687 |
+
async def update_appointment(
|
| 688 |
+
appointment_id: str,
|
| 689 |
+
appointment_data: AppointmentUpdate,
|
| 690 |
+
current_user: dict = Depends(get_current_user),
|
| 691 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 692 |
+
):
|
| 693 |
+
"""Update an appointment"""
|
| 694 |
+
if not is_valid_object_id(appointment_id):
|
| 695 |
+
raise HTTPException(
|
| 696 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 697 |
+
detail="Invalid appointment ID"
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 701 |
+
if not appointment:
|
| 702 |
+
raise HTTPException(
|
| 703 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 704 |
+
detail="Appointment not found"
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
# Check permissions
|
| 708 |
+
can_update = False
|
| 709 |
+
if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 710 |
+
can_update = True
|
| 711 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 712 |
+
if appointment["doctor_id"] == ObjectId(current_user["_id"]):
|
| 713 |
+
can_update = True
|
| 714 |
+
elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 715 |
+
if appointment["patient_id"] == ObjectId(current_user["_id"]):
|
| 716 |
+
# Patients can only update certain fields
|
| 717 |
+
can_update = True
|
| 718 |
+
|
| 719 |
+
if not can_update:
|
| 720 |
+
raise HTTPException(
|
| 721 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 722 |
+
detail="You can only update appointments you're involved with"
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
# Build update data
|
| 726 |
+
update_data = {"updated_at": datetime.now()}
|
| 727 |
+
|
| 728 |
+
if appointment_data.date is not None:
|
| 729 |
+
try:
|
| 730 |
+
# Store as string for MongoDB compatibility
|
| 731 |
+
update_data["date"] = appointment_data.date
|
| 732 |
+
except ValueError:
|
| 733 |
+
raise HTTPException(
|
| 734 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 735 |
+
detail="Invalid date format. Use YYYY-MM-DD"
|
| 736 |
+
)
|
| 737 |
+
if appointment_data.time is not None:
|
| 738 |
+
try:
|
| 739 |
+
# Store as string for MongoDB compatibility
|
| 740 |
+
update_data["time"] = appointment_data.time
|
| 741 |
+
except ValueError:
|
| 742 |
+
raise HTTPException(
|
| 743 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 744 |
+
detail="Invalid time format. Use HH:MM:SS"
|
| 745 |
+
)
|
| 746 |
+
if appointment_data.type is not None:
|
| 747 |
+
update_data["type"] = appointment_data.type
|
| 748 |
+
if appointment_data.reason is not None:
|
| 749 |
+
update_data["reason"] = appointment_data.reason
|
| 750 |
+
if appointment_data.notes is not None:
|
| 751 |
+
update_data["notes"] = appointment_data.notes
|
| 752 |
+
if appointment_data.duration is not None:
|
| 753 |
+
update_data["duration"] = appointment_data.duration
|
| 754 |
+
|
| 755 |
+
# Only doctors and admins can update status
|
| 756 |
+
if appointment_data.status is not None and (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or ('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
|
| 757 |
+
update_data["status"] = appointment_data.status
|
| 758 |
+
|
| 759 |
+
# Check for conflicts if date/time is being updated
|
| 760 |
+
if appointment_data.date is not None or appointment_data.time is not None:
|
| 761 |
+
new_date = update_data.get("date") or appointment["date"]
|
| 762 |
+
new_time = update_data.get("time") or appointment["time"]
|
| 763 |
+
|
| 764 |
+
existing_appointment = await db_client.appointments.find_one({
|
| 765 |
+
"_id": {"$ne": ObjectId(appointment_id)},
|
| 766 |
+
"doctor_id": appointment["doctor_id"],
|
| 767 |
+
"date": new_date,
|
| 768 |
+
"time": new_time,
|
| 769 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 770 |
+
})
|
| 771 |
+
|
| 772 |
+
if existing_appointment:
|
| 773 |
+
raise HTTPException(
|
| 774 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 775 |
+
detail="This time slot is already booked"
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
# Update appointment
|
| 779 |
+
await db_client.appointments.update_one(
|
| 780 |
+
{"_id": ObjectId(appointment_id)},
|
| 781 |
+
{"$set": update_data}
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
# If appointment status is being changed to confirmed, assign patient to doctor
|
| 785 |
+
if appointment_data.status == "confirmed":
|
| 786 |
+
try:
|
| 787 |
+
# Get patient details from users collection
|
| 788 |
+
patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
|
| 789 |
+
if patient:
|
| 790 |
+
# Check if patient already exists in patients collection
|
| 791 |
+
existing_patient = await patients_collection.find_one({
|
| 792 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 793 |
+
})
|
| 794 |
+
|
| 795 |
+
if existing_patient:
|
| 796 |
+
# Update existing patient to assign to this doctor
|
| 797 |
+
await patients_collection.update_one(
|
| 798 |
+
{"_id": existing_patient["_id"]},
|
| 799 |
+
{
|
| 800 |
+
"$set": {
|
| 801 |
+
"assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
|
| 802 |
+
"updated_at": datetime.now()
|
| 803 |
+
}
|
| 804 |
+
}
|
| 805 |
+
)
|
| 806 |
+
else:
|
| 807 |
+
# Create new patient record in patients collection
|
| 808 |
+
patient_doc = {
|
| 809 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 810 |
+
"full_name": patient.get("full_name", ""),
|
| 811 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 812 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 813 |
+
"address": patient.get("address"),
|
| 814 |
+
"city": patient.get("city"),
|
| 815 |
+
"state": patient.get("state"),
|
| 816 |
+
"postal_code": patient.get("postal_code"),
|
| 817 |
+
"country": patient.get("country"),
|
| 818 |
+
"national_id": patient.get("national_id"),
|
| 819 |
+
"blood_type": patient.get("blood_type"),
|
| 820 |
+
"allergies": patient.get("allergies", []),
|
| 821 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 822 |
+
"medications": patient.get("medications", []),
|
| 823 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 824 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 825 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 826 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 827 |
+
"notes": patient.get("notes", []),
|
| 828 |
+
"source": "direct", # Patient came through appointment booking
|
| 829 |
+
"status": "active",
|
| 830 |
+
"assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
|
| 831 |
+
"registration_date": datetime.now(),
|
| 832 |
+
"created_at": datetime.now(),
|
| 833 |
+
"updated_at": datetime.now()
|
| 834 |
+
}
|
| 835 |
+
await patients_collection.insert_one(patient_doc)
|
| 836 |
+
|
| 837 |
+
print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment['doctor_id']}")
|
| 838 |
+
except Exception as e:
|
| 839 |
+
print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
|
| 840 |
+
# Don't fail the appointment update if patient assignment fails
|
| 841 |
+
|
| 842 |
+
# Get updated appointment
|
| 843 |
+
updated_appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 844 |
+
|
| 845 |
+
# Get patient and doctor names
|
| 846 |
+
patient = await db_client.users.find_one({"_id": updated_appointment["patient_id"]})
|
| 847 |
+
doctor = await db_client.users.find_one({"_id": updated_appointment["doctor_id"]})
|
| 848 |
+
|
| 849 |
+
# Convert string date/time back to objects for response
|
| 850 |
+
apt_date = datetime.strptime(updated_appointment["date"], '%Y-%m-%d').date() if isinstance(updated_appointment["date"], str) else updated_appointment["date"]
|
| 851 |
+
apt_time = datetime.strptime(updated_appointment["time"], '%H:%M:%S').time() if isinstance(updated_appointment["time"], str) else updated_appointment["time"]
|
| 852 |
+
|
| 853 |
+
return AppointmentResponse(
|
| 854 |
+
id=str(updated_appointment["_id"]),
|
| 855 |
+
patient_id=str(updated_appointment["patient_id"]),
|
| 856 |
+
doctor_id=str(updated_appointment["doctor_id"]),
|
| 857 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 858 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 859 |
+
date=apt_date,
|
| 860 |
+
time=apt_time,
|
| 861 |
+
type=updated_appointment.get("type", "consultation"), # Default to consultation if missing
|
| 862 |
+
status=updated_appointment.get("status", "pending"), # Default to pending if missing
|
| 863 |
+
reason=updated_appointment.get("reason"),
|
| 864 |
+
notes=updated_appointment.get("notes"),
|
| 865 |
+
duration=updated_appointment.get("duration", 30),
|
| 866 |
+
created_at=updated_appointment.get("created_at", datetime.now()),
|
| 867 |
+
updated_at=updated_appointment.get("updated_at", datetime.now())
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
@router.delete("/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 871 |
+
async def delete_appointment(
|
| 872 |
+
appointment_id: str,
|
| 873 |
+
current_user: dict = Depends(get_current_user),
|
| 874 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 875 |
+
):
|
| 876 |
+
"""Delete an appointment"""
|
| 877 |
+
if not is_valid_object_id(appointment_id):
|
| 878 |
+
raise HTTPException(
|
| 879 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 880 |
+
detail="Invalid appointment ID"
|
| 881 |
+
)
|
| 882 |
+
|
| 883 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 884 |
+
if not appointment:
|
| 885 |
+
raise HTTPException(
|
| 886 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 887 |
+
detail="Appointment not found"
|
| 888 |
+
)
|
| 889 |
+
|
| 890 |
+
# Check permissions
|
| 891 |
+
can_delete = False
|
| 892 |
+
if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 893 |
+
can_delete = True
|
| 894 |
+
elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 895 |
+
if appointment["patient_id"] == ObjectId(current_user["_id"]):
|
| 896 |
+
can_delete = True
|
| 897 |
+
|
| 898 |
+
if not can_delete:
|
| 899 |
+
raise HTTPException(
|
| 900 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 901 |
+
detail="You can only delete your own appointments"
|
| 902 |
+
)
|
| 903 |
+
|
| 904 |
+
await db_client.appointments.delete_one({"_id": ObjectId(appointment_id)})
|
deployment/routes/auth.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status, Request, Query
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from db.mongo import users_collection
|
| 5 |
+
from core.security import hash_password, verify_password, create_access_token, get_current_user
|
| 6 |
+
from models.schemas import (
|
| 7 |
+
SignupForm,
|
| 8 |
+
TokenResponse,
|
| 9 |
+
DoctorCreate,
|
| 10 |
+
AdminCreate,
|
| 11 |
+
ProfileUpdate,
|
| 12 |
+
PasswordChange,
|
| 13 |
+
AdminUserUpdate,
|
| 14 |
+
AdminPasswordReset,
|
| 15 |
+
)
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
# Configure logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
@router.post("/signup", status_code=status.HTTP_201_CREATED)
|
| 29 |
+
async def signup(data: SignupForm):
|
| 30 |
+
"""
|
| 31 |
+
Patient registration endpoint - only patients can register through signup
|
| 32 |
+
Doctors and admins must be created by existing admins
|
| 33 |
+
"""
|
| 34 |
+
logger.info(f"Patient signup attempt for email: {data.email}")
|
| 35 |
+
logger.info(f"Received signup data: {data.dict()}")
|
| 36 |
+
email = data.email.lower().strip()
|
| 37 |
+
existing = await users_collection.find_one({"email": email})
|
| 38 |
+
if existing:
|
| 39 |
+
logger.warning(f"Signup failed: Email already exists: {email}")
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 42 |
+
detail="Email already exists"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
hashed_pw = hash_password(data.password)
|
| 46 |
+
user_doc = {
|
| 47 |
+
"email": email,
|
| 48 |
+
"full_name": data.full_name.strip(),
|
| 49 |
+
"password": hashed_pw,
|
| 50 |
+
"roles": ["patient"], # Only patients can register through signup
|
| 51 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 52 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 53 |
+
"device_token": "" # Default empty device token for patients
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
result = await users_collection.insert_one(user_doc)
|
| 58 |
+
logger.info(f"User created successfully: {email}")
|
| 59 |
+
return {
|
| 60 |
+
"status": "success",
|
| 61 |
+
"id": str(result.inserted_id),
|
| 62 |
+
"email": email
|
| 63 |
+
}
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Failed to create user {email}: {str(e)}")
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 68 |
+
detail=f"Failed to create user: {str(e)}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@router.post("/admin/doctors", status_code=status.HTTP_201_CREATED)
|
| 72 |
+
async def create_doctor(
|
| 73 |
+
data: DoctorCreate,
|
| 74 |
+
current_user: dict = Depends(get_current_user)
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Create doctor account - only admins can create doctor accounts
|
| 78 |
+
"""
|
| 79 |
+
logger.info(f"Doctor creation attempt by {current_user.get('email')}")
|
| 80 |
+
if 'admin' not in current_user.get('roles', []):
|
| 81 |
+
logger.warning(f"Unauthorized doctor creation attempt by {current_user.get('email')}")
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 84 |
+
detail="Only admins can create doctor accounts"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
email = data.email.lower().strip()
|
| 88 |
+
existing = await users_collection.find_one({"email": email})
|
| 89 |
+
if existing:
|
| 90 |
+
logger.warning(f"Doctor creation failed: Email already exists: {email}")
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 93 |
+
detail="Email already exists"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
hashed_pw = hash_password(data.password)
|
| 97 |
+
doctor_doc = {
|
| 98 |
+
"email": email,
|
| 99 |
+
"full_name": data.full_name.strip(),
|
| 100 |
+
"password": hashed_pw,
|
| 101 |
+
"roles": data.roles, # Support multiple roles
|
| 102 |
+
"specialty": data.specialty,
|
| 103 |
+
"license_number": data.license_number,
|
| 104 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 105 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 106 |
+
"device_token": data.device_token or ""
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
result = await users_collection.insert_one(doctor_doc)
|
| 111 |
+
logger.info(f"Doctor created successfully: {email}")
|
| 112 |
+
return {
|
| 113 |
+
"status": "success",
|
| 114 |
+
"id": str(result.inserted_id),
|
| 115 |
+
"email": email
|
| 116 |
+
}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Failed to create doctor {email}: {str(e)}")
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 121 |
+
detail=f"Failed to create doctor: {str(e)}"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@router.post("/admin/admins", status_code=status.HTTP_201_CREATED)
|
| 125 |
+
async def create_admin(
|
| 126 |
+
data: AdminCreate,
|
| 127 |
+
current_user: dict = Depends(get_current_user)
|
| 128 |
+
):
|
| 129 |
+
"""
|
| 130 |
+
Create admin account - only existing admins can create new admin accounts
|
| 131 |
+
"""
|
| 132 |
+
logger.info(f"Admin creation attempt by {current_user.get('email')}")
|
| 133 |
+
if 'admin' not in current_user.get('roles', []):
|
| 134 |
+
logger.warning(f"Unauthorized admin creation attempt by {current_user.get('email')}")
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 137 |
+
detail="Only admins can create admin accounts"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
email = data.email.lower().strip()
|
| 141 |
+
existing = await users_collection.find_one({"email": email})
|
| 142 |
+
if existing:
|
| 143 |
+
logger.warning(f"Admin creation failed: Email already exists: {email}")
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 146 |
+
detail="Email already exists"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
hashed_pw = hash_password(data.password)
|
| 150 |
+
admin_doc = {
|
| 151 |
+
"email": email,
|
| 152 |
+
"full_name": data.full_name.strip(),
|
| 153 |
+
"password": hashed_pw,
|
| 154 |
+
"roles": data.roles, # Support multiple roles
|
| 155 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 156 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 157 |
+
"device_token": ""
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
result = await users_collection.insert_one(admin_doc)
|
| 162 |
+
logger.info(f"Admin created successfully: {email}")
|
| 163 |
+
return {
|
| 164 |
+
"status": "success",
|
| 165 |
+
"id": str(result.inserted_id),
|
| 166 |
+
"email": email
|
| 167 |
+
}
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Failed to create admin {email}: {str(e)}")
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 172 |
+
detail=f"Failed to create admin: {str(e)}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
@router.post("/login", response_model=TokenResponse)
|
| 176 |
+
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 177 |
+
logger.info(f"Login attempt for email: {form_data.username}")
|
| 178 |
+
user = await users_collection.find_one({"email": form_data.username.lower()})
|
| 179 |
+
if not user or not verify_password(form_data.password, user["password"]):
|
| 180 |
+
logger.warning(f"Login failed for {form_data.username}: Invalid credentials")
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 183 |
+
detail="Invalid credentials",
|
| 184 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Update device token if provided in form_data (e.g., from frontend)
|
| 188 |
+
if hasattr(form_data, 'device_token') and form_data.device_token:
|
| 189 |
+
await users_collection.update_one(
|
| 190 |
+
{"email": user["email"]},
|
| 191 |
+
{"$set": {"device_token": form_data.device_token}}
|
| 192 |
+
)
|
| 193 |
+
logger.info(f"Device token updated for {form_data.username}")
|
| 194 |
+
|
| 195 |
+
access_token = create_access_token(data={"sub": user["email"]})
|
| 196 |
+
logger.info(f"Successful login for {form_data.username}")
|
| 197 |
+
return {
|
| 198 |
+
"access_token": access_token,
|
| 199 |
+
"token_type": "bearer",
|
| 200 |
+
"roles": user.get("roles", ["patient"]) # Return all roles
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
@router.get("/me")
|
| 204 |
+
async def get_me(request: Request, current_user: dict = Depends(get_current_user)):
|
| 205 |
+
logger.info(f"Fetching user profile for {current_user['email']} at {datetime.utcnow().isoformat()}")
|
| 206 |
+
print(f"Headers: {request.headers}")
|
| 207 |
+
try:
|
| 208 |
+
user = await users_collection.find_one({"email": current_user["email"]})
|
| 209 |
+
if not user:
|
| 210 |
+
logger.warning(f"User not found: {current_user['email']}")
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 213 |
+
detail="User not found"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Handle both "role" (singular) and "roles" (array) formats
|
| 217 |
+
user_roles = user.get("roles", [])
|
| 218 |
+
if not user_roles and user.get("role"):
|
| 219 |
+
# If roles array is empty but role field exists, convert to array
|
| 220 |
+
user_roles = [user.get("role")]
|
| 221 |
+
|
| 222 |
+
print(f"🔍 User from DB: {user}")
|
| 223 |
+
print(f"🔍 User roles: {user_roles}")
|
| 224 |
+
|
| 225 |
+
response = {
|
| 226 |
+
"id": str(user["_id"]),
|
| 227 |
+
"email": user["email"],
|
| 228 |
+
"full_name": user.get("full_name", ""),
|
| 229 |
+
"roles": user_roles, # Return all roles
|
| 230 |
+
"specialty": user.get("specialty"),
|
| 231 |
+
"created_at": user.get("created_at"),
|
| 232 |
+
"updated_at": user.get("updated_at"),
|
| 233 |
+
"device_token": user.get("device_token", "") # Include device token in response
|
| 234 |
+
}
|
| 235 |
+
logger.info(f"User profile retrieved for {current_user['email']} at {datetime.utcnow().isoformat()}")
|
| 236 |
+
return response
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.error(f"Database error for user {current_user['email']}: {str(e)} at {datetime.utcnow().isoformat()}")
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 241 |
+
detail=f"Database error: {str(e)}"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
@router.get("/users")
|
| 245 |
+
async def list_users(
|
| 246 |
+
role: Optional[str] = None,
|
| 247 |
+
search: Optional[str] = Query(None, description="Search by name or email"),
|
| 248 |
+
current_user: dict = Depends(get_current_user)
|
| 249 |
+
):
|
| 250 |
+
"""
|
| 251 |
+
List users - admins can see all users, doctors can see patients, patients can only see themselves
|
| 252 |
+
"""
|
| 253 |
+
logger.info(f"User list request by {current_user.get('email')} with role filter: {role}")
|
| 254 |
+
|
| 255 |
+
# Build query based on user role and requested filter
|
| 256 |
+
query = {}
|
| 257 |
+
if role:
|
| 258 |
+
# support both role singlular and roles array in historical docs
|
| 259 |
+
query["roles"] = {"$in": [role]}
|
| 260 |
+
if search:
|
| 261 |
+
query["$or"] = [
|
| 262 |
+
{"full_name": {"$regex": search, "$options": "i"}},
|
| 263 |
+
{"email": {"$regex": search, "$options": "i"}},
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
# Role-based access control
|
| 267 |
+
if 'admin' in current_user.get('roles', []):
|
| 268 |
+
# Admins can see all users
|
| 269 |
+
pass
|
| 270 |
+
elif 'doctor' in current_user.get('roles', []):
|
| 271 |
+
# Doctors can only see patients
|
| 272 |
+
query["roles"] = {"$in": ["patient"]}
|
| 273 |
+
elif 'patient' in current_user.get('roles', []):
|
| 274 |
+
# Patients can only see themselves
|
| 275 |
+
query["email"] = current_user.get('email')
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
users = await users_collection.find(query).limit(500).to_list(length=500)
|
| 279 |
+
# Remove sensitive information
|
| 280 |
+
for user in users:
|
| 281 |
+
user["id"] = str(user["_id"])
|
| 282 |
+
del user["_id"]
|
| 283 |
+
del user["password"]
|
| 284 |
+
user.pop("device_token", None) # Safely remove device_token if it exists
|
| 285 |
+
|
| 286 |
+
logger.info(f"Retrieved {len(users)} users for {current_user.get('email')}")
|
| 287 |
+
return users
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error retrieving users: {str(e)}")
|
| 290 |
+
raise HTTPException(
|
| 291 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 292 |
+
detail=f"Failed to retrieve users: {str(e)}"
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
@router.put("/admin/users/{user_id}")
|
| 296 |
+
async def admin_update_user(
|
| 297 |
+
user_id: str,
|
| 298 |
+
data: AdminUserUpdate,
|
| 299 |
+
current_user: dict = Depends(get_current_user)
|
| 300 |
+
):
|
| 301 |
+
if 'admin' not in current_user.get('roles', []):
|
| 302 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 303 |
+
try:
|
| 304 |
+
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
| 305 |
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 306 |
+
result = await users_collection.update_one({"_id": __import__('bson').ObjectId(user_id)}, {"$set": update_data})
|
| 307 |
+
if result.matched_count == 0:
|
| 308 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 309 |
+
return {"status": "success"}
|
| 310 |
+
except Exception as e:
|
| 311 |
+
raise HTTPException(status_code=500, detail=f"Failed to update user: {str(e)}")
|
| 312 |
+
|
| 313 |
+
@router.delete("/admin/users/{user_id}")
|
| 314 |
+
async def admin_delete_user(
|
| 315 |
+
user_id: str,
|
| 316 |
+
current_user: dict = Depends(get_current_user)
|
| 317 |
+
):
|
| 318 |
+
if 'admin' not in current_user.get('roles', []):
|
| 319 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 320 |
+
try:
|
| 321 |
+
result = await users_collection.delete_one({"_id": __import__('bson').ObjectId(user_id)})
|
| 322 |
+
if result.deleted_count == 0:
|
| 323 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 324 |
+
return {"status": "success"}
|
| 325 |
+
except Exception as e:
|
| 326 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete user: {str(e)}")
|
| 327 |
+
|
| 328 |
+
@router.post("/admin/users/{user_id}/reset-password")
|
| 329 |
+
async def admin_reset_password(
|
| 330 |
+
user_id: str,
|
| 331 |
+
data: AdminPasswordReset,
|
| 332 |
+
current_user: dict = Depends(get_current_user)
|
| 333 |
+
):
|
| 334 |
+
if 'admin' not in current_user.get('roles', []):
|
| 335 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 336 |
+
if len(data.new_password) < 6:
|
| 337 |
+
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
| 338 |
+
try:
|
| 339 |
+
hashed = hash_password(data.new_password)
|
| 340 |
+
result = await users_collection.update_one(
|
| 341 |
+
{"_id": __import__('bson').ObjectId(user_id)},
|
| 342 |
+
{"$set": {"password": hashed, "updated_at": datetime.utcnow().isoformat()}}
|
| 343 |
+
)
|
| 344 |
+
if result.matched_count == 0:
|
| 345 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 346 |
+
return {"status": "success"}
|
| 347 |
+
except Exception as e:
|
| 348 |
+
raise HTTPException(status_code=500, detail=f"Failed to reset password: {str(e)}")
|
| 349 |
+
|
| 350 |
+
@router.put("/profile", status_code=status.HTTP_200_OK)
|
| 351 |
+
async def update_profile(
|
| 352 |
+
data: ProfileUpdate,
|
| 353 |
+
current_user: dict = Depends(get_current_user)
|
| 354 |
+
):
|
| 355 |
+
"""
|
| 356 |
+
Update user profile - users can update their own profile
|
| 357 |
+
"""
|
| 358 |
+
logger.info(f"Profile update attempt by {current_user.get('email')}")
|
| 359 |
+
|
| 360 |
+
# Build update data (only include fields that are provided)
|
| 361 |
+
update_data = {}
|
| 362 |
+
if data.full_name is not None:
|
| 363 |
+
update_data["full_name"] = data.full_name.strip()
|
| 364 |
+
if data.phone is not None:
|
| 365 |
+
update_data["phone"] = data.phone.strip()
|
| 366 |
+
if data.address is not None:
|
| 367 |
+
update_data["address"] = data.address.strip()
|
| 368 |
+
if data.date_of_birth is not None:
|
| 369 |
+
update_data["date_of_birth"] = data.date_of_birth
|
| 370 |
+
if data.gender is not None:
|
| 371 |
+
update_data["gender"] = data.gender.strip()
|
| 372 |
+
if data.specialty is not None:
|
| 373 |
+
update_data["specialty"] = data.specialty.strip()
|
| 374 |
+
if data.license_number is not None:
|
| 375 |
+
update_data["license_number"] = data.license_number.strip()
|
| 376 |
+
|
| 377 |
+
# Add updated timestamp
|
| 378 |
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
result = await users_collection.update_one(
|
| 382 |
+
{"email": current_user.get('email')},
|
| 383 |
+
{"$set": update_data}
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
if result.modified_count == 0:
|
| 387 |
+
raise HTTPException(
|
| 388 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 389 |
+
detail="User not found"
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
logger.info(f"Profile updated successfully for {current_user.get('email')}")
|
| 393 |
+
return {
|
| 394 |
+
"status": "success",
|
| 395 |
+
"message": "Profile updated successfully"
|
| 396 |
+
}
|
| 397 |
+
except Exception as e:
|
| 398 |
+
logger.error(f"Failed to update profile for {current_user.get('email')}: {str(e)}")
|
| 399 |
+
raise HTTPException(
|
| 400 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 401 |
+
detail=f"Failed to update profile: {str(e)}"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
| 405 |
+
async def change_password(
|
| 406 |
+
data: PasswordChange,
|
| 407 |
+
current_user: dict = Depends(get_current_user)
|
| 408 |
+
):
|
| 409 |
+
"""
|
| 410 |
+
Change user password - users can change their own password
|
| 411 |
+
"""
|
| 412 |
+
logger.info(f"Password change attempt by {current_user.get('email')}")
|
| 413 |
+
|
| 414 |
+
# Verify current password
|
| 415 |
+
if not verify_password(data.current_password, current_user.get('password')):
|
| 416 |
+
logger.warning(f"Password change failed: incorrect current password for {current_user.get('email')}")
|
| 417 |
+
raise HTTPException(
|
| 418 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 419 |
+
detail="Current password is incorrect"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
# Validate new password
|
| 423 |
+
if len(data.new_password) < 6:
|
| 424 |
+
raise HTTPException(
|
| 425 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 426 |
+
detail="New password must be at least 6 characters long"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
# Hash new password
|
| 430 |
+
hashed_new_password = hash_password(data.new_password)
|
| 431 |
+
|
| 432 |
+
try:
|
| 433 |
+
result = await users_collection.update_one(
|
| 434 |
+
{"email": current_user.get('email')},
|
| 435 |
+
{
|
| 436 |
+
"$set": {
|
| 437 |
+
"password": hashed_new_password,
|
| 438 |
+
"updated_at": datetime.utcnow().isoformat()
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
if result.modified_count == 0:
|
| 444 |
+
raise HTTPException(
|
| 445 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 446 |
+
detail="User not found"
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
logger.info(f"Password changed successfully for {current_user.get('email')}")
|
| 450 |
+
return {
|
| 451 |
+
"status": "success",
|
| 452 |
+
"message": "Password changed successfully"
|
| 453 |
+
}
|
| 454 |
+
except Exception as e:
|
| 455 |
+
logger.error(f"Failed to change password for {current_user.get('email')}: {str(e)}")
|
| 456 |
+
raise HTTPException(
|
| 457 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 458 |
+
detail=f"Failed to change password: {str(e)}"
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
# Export the router as 'auth' for api.__init__.py
|
| 462 |
+
auth = router
|
deployment/routes/fhir_integration.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
import httpx
|
| 4 |
+
import json
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from ..auth.auth import get_current_user
|
| 7 |
+
from models.schemas import User
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
# HAPI FHIR Test Server URL
|
| 12 |
+
HAPI_FHIR_BASE_URL = "https://hapi.fhir.org/baseR4"
|
| 13 |
+
|
| 14 |
+
class FHIRIntegration:
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.base_url = HAPI_FHIR_BASE_URL
|
| 17 |
+
self.client = httpx.AsyncClient(timeout=30.0)
|
| 18 |
+
|
| 19 |
+
async def search_patients(self, limit: int = 10, offset: int = 0) -> dict:
|
| 20 |
+
"""Search for patients in HAPI FHIR Test Server"""
|
| 21 |
+
try:
|
| 22 |
+
url = f"{self.base_url}/Patient"
|
| 23 |
+
params = {
|
| 24 |
+
"_count": limit,
|
| 25 |
+
"_getpagesoffset": offset
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
response = await self.client.get(url, params=params)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
|
| 31 |
+
return response.json()
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=500, detail=f"FHIR server error: {str(e)}")
|
| 34 |
+
|
| 35 |
+
async def get_patient_by_id(self, patient_id: str) -> dict:
|
| 36 |
+
"""Get a specific patient by ID from HAPI FHIR Test Server"""
|
| 37 |
+
try:
|
| 38 |
+
url = f"{self.base_url}/Patient/{patient_id}"
|
| 39 |
+
response = await self.client.get(url)
|
| 40 |
+
response.raise_for_status()
|
| 41 |
+
|
| 42 |
+
return response.json()
|
| 43 |
+
except Exception as e:
|
| 44 |
+
raise HTTPException(status_code=404, detail=f"Patient not found: {str(e)}")
|
| 45 |
+
|
| 46 |
+
async def get_patient_observations(self, patient_id: str) -> dict:
|
| 47 |
+
"""Get observations (vital signs, lab results) for a patient"""
|
| 48 |
+
try:
|
| 49 |
+
url = f"{self.base_url}/Observation"
|
| 50 |
+
params = {
|
| 51 |
+
"patient": patient_id,
|
| 52 |
+
"_count": 100
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
response = await self.client.get(url, params=params)
|
| 56 |
+
response.raise_for_status()
|
| 57 |
+
|
| 58 |
+
return response.json()
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HTTPException(status_code=500, detail=f"Error fetching observations: {str(e)}")
|
| 61 |
+
|
| 62 |
+
async def get_patient_medications(self, patient_id: str) -> dict:
|
| 63 |
+
"""Get medications for a patient"""
|
| 64 |
+
try:
|
| 65 |
+
url = f"{self.base_url}/MedicationRequest"
|
| 66 |
+
params = {
|
| 67 |
+
"patient": patient_id,
|
| 68 |
+
"_count": 100
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
response = await self.client.get(url, params=params)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
|
| 74 |
+
return response.json()
|
| 75 |
+
except Exception as e:
|
| 76 |
+
raise HTTPException(status_code=500, detail=f"Error fetching medications: {str(e)}")
|
| 77 |
+
|
| 78 |
+
async def get_patient_conditions(self, patient_id: str) -> dict:
|
| 79 |
+
"""Get conditions (diagnoses) for a patient"""
|
| 80 |
+
try:
|
| 81 |
+
url = f"{self.base_url}/Condition"
|
| 82 |
+
params = {
|
| 83 |
+
"patient": patient_id,
|
| 84 |
+
"_count": 100
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
response = await self.client.get(url, params=params)
|
| 88 |
+
response.raise_for_status()
|
| 89 |
+
|
| 90 |
+
return response.json()
|
| 91 |
+
except Exception as e:
|
| 92 |
+
raise HTTPException(status_code=500, detail=f"Error fetching conditions: {str(e)}")
|
| 93 |
+
|
| 94 |
+
async def get_patient_encounters(self, patient_id: str) -> dict:
|
| 95 |
+
"""Get encounters (visits) for a patient"""
|
| 96 |
+
try:
|
| 97 |
+
url = f"{self.base_url}/Encounter"
|
| 98 |
+
params = {
|
| 99 |
+
"patient": patient_id,
|
| 100 |
+
"_count": 100
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
response = await self.client.get(url, params=params)
|
| 104 |
+
response.raise_for_status()
|
| 105 |
+
|
| 106 |
+
return response.json()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
raise HTTPException(status_code=500, detail=f"Error fetching encounters: {str(e)}")
|
| 109 |
+
|
| 110 |
+
# Initialize FHIR integration
|
| 111 |
+
fhir_integration = FHIRIntegration()
|
| 112 |
+
|
| 113 |
+
@router.get("/fhir/patients")
|
| 114 |
+
async def get_fhir_patients(
|
| 115 |
+
limit: int = 10,
|
| 116 |
+
offset: int = 0,
|
| 117 |
+
current_user: User = Depends(get_current_user)
|
| 118 |
+
):
|
| 119 |
+
"""Get patients from HAPI FHIR Test Server"""
|
| 120 |
+
try:
|
| 121 |
+
patients_data = await fhir_integration.search_patients(limit, offset)
|
| 122 |
+
|
| 123 |
+
# Transform FHIR patients to our format
|
| 124 |
+
transformed_patients = []
|
| 125 |
+
for patient in patients_data.get("entry", []):
|
| 126 |
+
fhir_patient = patient.get("resource", {})
|
| 127 |
+
|
| 128 |
+
# Extract patient information
|
| 129 |
+
patient_info = {
|
| 130 |
+
"fhir_id": fhir_patient.get("id"),
|
| 131 |
+
"full_name": "",
|
| 132 |
+
"gender": fhir_patient.get("gender", "unknown"),
|
| 133 |
+
"date_of_birth": "",
|
| 134 |
+
"address": "",
|
| 135 |
+
"phone": "",
|
| 136 |
+
"email": ""
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# Extract name
|
| 140 |
+
if fhir_patient.get("name"):
|
| 141 |
+
name_parts = []
|
| 142 |
+
for name in fhir_patient["name"]:
|
| 143 |
+
if name.get("given"):
|
| 144 |
+
name_parts.extend(name["given"])
|
| 145 |
+
if name.get("family"):
|
| 146 |
+
name_parts.append(name["family"])
|
| 147 |
+
patient_info["full_name"] = " ".join(name_parts)
|
| 148 |
+
|
| 149 |
+
# Extract birth date
|
| 150 |
+
if fhir_patient.get("birthDate"):
|
| 151 |
+
patient_info["date_of_birth"] = fhir_patient["birthDate"]
|
| 152 |
+
|
| 153 |
+
# Extract address
|
| 154 |
+
if fhir_patient.get("address"):
|
| 155 |
+
address_parts = []
|
| 156 |
+
for address in fhir_patient["address"]:
|
| 157 |
+
if address.get("line"):
|
| 158 |
+
address_parts.extend(address["line"])
|
| 159 |
+
if address.get("city"):
|
| 160 |
+
address_parts.append(address["city"])
|
| 161 |
+
if address.get("state"):
|
| 162 |
+
address_parts.append(address["state"])
|
| 163 |
+
if address.get("postalCode"):
|
| 164 |
+
address_parts.append(address["postalCode"])
|
| 165 |
+
patient_info["address"] = ", ".join(address_parts)
|
| 166 |
+
|
| 167 |
+
# Extract contact information
|
| 168 |
+
if fhir_patient.get("telecom"):
|
| 169 |
+
for telecom in fhir_patient["telecom"]:
|
| 170 |
+
if telecom.get("system") == "phone":
|
| 171 |
+
patient_info["phone"] = telecom.get("value", "")
|
| 172 |
+
elif telecom.get("system") == "email":
|
| 173 |
+
patient_info["email"] = telecom.get("value", "")
|
| 174 |
+
|
| 175 |
+
transformed_patients.append(patient_info)
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
"patients": transformed_patients,
|
| 179 |
+
"total": patients_data.get("total", len(transformed_patients)),
|
| 180 |
+
"count": len(transformed_patients)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
raise HTTPException(status_code=500, detail=f"Error fetching FHIR patients: {str(e)}")
|
| 185 |
+
|
| 186 |
+
@router.get("/fhir/patients/{patient_id}")
|
| 187 |
+
async def get_fhir_patient_details(
|
| 188 |
+
patient_id: str,
|
| 189 |
+
current_user: User = Depends(get_current_user)
|
| 190 |
+
):
|
| 191 |
+
"""Get detailed patient information from HAPI FHIR Test Server"""
|
| 192 |
+
try:
|
| 193 |
+
# Get basic patient info
|
| 194 |
+
patient_data = await fhir_integration.get_patient_by_id(patient_id)
|
| 195 |
+
|
| 196 |
+
# Get additional patient data
|
| 197 |
+
observations = await fhir_integration.get_patient_observations(patient_id)
|
| 198 |
+
medications = await fhir_integration.get_patient_medications(patient_id)
|
| 199 |
+
conditions = await fhir_integration.get_patient_conditions(patient_id)
|
| 200 |
+
encounters = await fhir_integration.get_patient_encounters(patient_id)
|
| 201 |
+
|
| 202 |
+
# Transform and combine all data
|
| 203 |
+
patient_info = {
|
| 204 |
+
"fhir_id": patient_data.get("id"),
|
| 205 |
+
"full_name": "",
|
| 206 |
+
"gender": patient_data.get("gender", "unknown"),
|
| 207 |
+
"date_of_birth": patient_data.get("birthDate", ""),
|
| 208 |
+
"address": "",
|
| 209 |
+
"phone": "",
|
| 210 |
+
"email": "",
|
| 211 |
+
"observations": [],
|
| 212 |
+
"medications": [],
|
| 213 |
+
"conditions": [],
|
| 214 |
+
"encounters": []
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Extract name
|
| 218 |
+
if patient_data.get("name"):
|
| 219 |
+
name_parts = []
|
| 220 |
+
for name in patient_data["name"]:
|
| 221 |
+
if name.get("given"):
|
| 222 |
+
name_parts.extend(name["given"])
|
| 223 |
+
if name.get("family"):
|
| 224 |
+
name_parts.append(name["family"])
|
| 225 |
+
patient_info["full_name"] = " ".join(name_parts)
|
| 226 |
+
|
| 227 |
+
# Extract address
|
| 228 |
+
if patient_data.get("address"):
|
| 229 |
+
address_parts = []
|
| 230 |
+
for address in patient_data["address"]:
|
| 231 |
+
if address.get("line"):
|
| 232 |
+
address_parts.extend(address["line"])
|
| 233 |
+
if address.get("city"):
|
| 234 |
+
address_parts.append(address["city"])
|
| 235 |
+
if address.get("state"):
|
| 236 |
+
address_parts.append(address["state"])
|
| 237 |
+
if address.get("postalCode"):
|
| 238 |
+
address_parts.append(address["postalCode"])
|
| 239 |
+
patient_info["address"] = ", ".join(address_parts)
|
| 240 |
+
|
| 241 |
+
# Extract contact information
|
| 242 |
+
if patient_data.get("telecom"):
|
| 243 |
+
for telecom in patient_data["telecom"]:
|
| 244 |
+
if telecom.get("system") == "phone":
|
| 245 |
+
patient_info["phone"] = telecom.get("value", "")
|
| 246 |
+
elif telecom.get("system") == "email":
|
| 247 |
+
patient_info["email"] = telecom.get("value", "")
|
| 248 |
+
|
| 249 |
+
# Transform observations
|
| 250 |
+
for obs in observations.get("entry", []):
|
| 251 |
+
resource = obs.get("resource", {})
|
| 252 |
+
if resource.get("code", {}).get("text"):
|
| 253 |
+
patient_info["observations"].append({
|
| 254 |
+
"type": resource["code"]["text"],
|
| 255 |
+
"value": resource.get("valueQuantity", {}).get("value"),
|
| 256 |
+
"unit": resource.get("valueQuantity", {}).get("unit"),
|
| 257 |
+
"date": resource.get("effectiveDateTime", "")
|
| 258 |
+
})
|
| 259 |
+
|
| 260 |
+
# Transform medications
|
| 261 |
+
for med in medications.get("entry", []):
|
| 262 |
+
resource = med.get("resource", {})
|
| 263 |
+
if resource.get("medicationCodeableConcept", {}).get("text"):
|
| 264 |
+
patient_info["medications"].append({
|
| 265 |
+
"name": resource["medicationCodeableConcept"]["text"],
|
| 266 |
+
"status": resource.get("status", ""),
|
| 267 |
+
"prescribed_date": resource.get("authoredOn", ""),
|
| 268 |
+
"dosage": resource.get("dosageInstruction", [{}])[0].get("text", "")
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
# Transform conditions
|
| 272 |
+
for condition in conditions.get("entry", []):
|
| 273 |
+
resource = condition.get("resource", {})
|
| 274 |
+
if resource.get("code", {}).get("text"):
|
| 275 |
+
patient_info["conditions"].append({
|
| 276 |
+
"name": resource["code"]["text"],
|
| 277 |
+
"status": resource.get("clinicalStatus", {}).get("text", ""),
|
| 278 |
+
"onset_date": resource.get("onsetDateTime", ""),
|
| 279 |
+
"severity": resource.get("severity", {}).get("text", "")
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
# Transform encounters
|
| 283 |
+
for encounter in encounters.get("entry", []):
|
| 284 |
+
resource = encounter.get("resource", {})
|
| 285 |
+
if resource.get("type"):
|
| 286 |
+
patient_info["encounters"].append({
|
| 287 |
+
"type": resource["type"][0].get("text", ""),
|
| 288 |
+
"status": resource.get("status", ""),
|
| 289 |
+
"start_date": resource.get("period", {}).get("start", ""),
|
| 290 |
+
"end_date": resource.get("period", {}).get("end", ""),
|
| 291 |
+
"service_provider": resource.get("serviceProvider", {}).get("display", "")
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
return patient_info
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
raise HTTPException(status_code=500, detail=f"Error fetching FHIR patient details: {str(e)}")
|
| 298 |
+
|
| 299 |
+
@router.post("/fhir/import-patient/{patient_id}")
|
| 300 |
+
async def import_fhir_patient(
|
| 301 |
+
patient_id: str,
|
| 302 |
+
current_user: User = Depends(get_current_user)
|
| 303 |
+
):
|
| 304 |
+
"""Import a patient from HAPI FHIR Test Server to our database"""
|
| 305 |
+
try:
|
| 306 |
+
from db.mongo import patients_collection
|
| 307 |
+
from bson import ObjectId
|
| 308 |
+
|
| 309 |
+
# Get patient data from FHIR server
|
| 310 |
+
patient_data = await fhir_integration.get_patient_by_id(patient_id)
|
| 311 |
+
|
| 312 |
+
# Transform FHIR data to our format
|
| 313 |
+
transformed_patient = {
|
| 314 |
+
"fhir_id": patient_data.get("id"),
|
| 315 |
+
"full_name": "",
|
| 316 |
+
"gender": patient_data.get("gender", "unknown"),
|
| 317 |
+
"date_of_birth": patient_data.get("birthDate", ""),
|
| 318 |
+
"address": "",
|
| 319 |
+
"phone": "",
|
| 320 |
+
"email": "",
|
| 321 |
+
"source": "fhir_import",
|
| 322 |
+
"status": "active",
|
| 323 |
+
"assigned_doctor_id": str(current_user.id),
|
| 324 |
+
"created_at": datetime.now(),
|
| 325 |
+
"updated_at": datetime.now()
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
# Extract name
|
| 329 |
+
if patient_data.get("name"):
|
| 330 |
+
name_parts = []
|
| 331 |
+
for name in patient_data["name"]:
|
| 332 |
+
if name.get("given"):
|
| 333 |
+
name_parts.extend(name["given"])
|
| 334 |
+
if name.get("family"):
|
| 335 |
+
name_parts.append(name["family"])
|
| 336 |
+
transformed_patient["full_name"] = " ".join(name_parts)
|
| 337 |
+
|
| 338 |
+
# Extract address
|
| 339 |
+
if patient_data.get("address"):
|
| 340 |
+
address_parts = []
|
| 341 |
+
for address in patient_data["address"]:
|
| 342 |
+
if address.get("line"):
|
| 343 |
+
address_parts.extend(address["line"])
|
| 344 |
+
if address.get("city"):
|
| 345 |
+
address_parts.append(address["city"])
|
| 346 |
+
if address.get("state"):
|
| 347 |
+
address_parts.append(address["state"])
|
| 348 |
+
if address.get("postalCode"):
|
| 349 |
+
address_parts.append(address["postalCode"])
|
| 350 |
+
transformed_patient["address"] = ", ".join(address_parts)
|
| 351 |
+
|
| 352 |
+
# Extract contact information
|
| 353 |
+
if patient_data.get("telecom"):
|
| 354 |
+
for telecom in patient_data["telecom"]:
|
| 355 |
+
if telecom.get("system") == "phone":
|
| 356 |
+
transformed_patient["phone"] = telecom.get("value", "")
|
| 357 |
+
elif telecom.get("system") == "email":
|
| 358 |
+
transformed_patient["email"] = telecom.get("value", "")
|
| 359 |
+
|
| 360 |
+
# Check if patient already exists
|
| 361 |
+
existing_patient = await patients_collection.find_one({"fhir_id": patient_data.get("id")})
|
| 362 |
+
if existing_patient:
|
| 363 |
+
raise HTTPException(status_code=400, detail="Patient already exists in database")
|
| 364 |
+
|
| 365 |
+
# Insert patient into database
|
| 366 |
+
result = await patients_collection.insert_one(transformed_patient)
|
| 367 |
+
|
| 368 |
+
return {
|
| 369 |
+
"message": "Patient imported successfully",
|
| 370 |
+
"patient_id": str(result.inserted_id),
|
| 371 |
+
"fhir_id": patient_data.get("id")
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
except Exception as e:
|
| 375 |
+
raise HTTPException(status_code=500, detail=f"Error importing FHIR patient: {str(e)}")
|
deployment/routes/messaging.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query, WebSocket, WebSocketDisconnect, UploadFile, File
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
import os
|
| 10 |
+
import uuid
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from core.security import get_current_user
|
| 14 |
+
from db.mongo import db
|
| 15 |
+
from models.schemas import (
|
| 16 |
+
MessageCreate, MessageUpdate, MessageResponse, MessageListResponse,
|
| 17 |
+
ConversationResponse, ConversationListResponse, MessageType, MessageStatus,
|
| 18 |
+
NotificationCreate, NotificationResponse, NotificationListResponse,
|
| 19 |
+
NotificationType, NotificationPriority
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/messaging", tags=["messaging"])
|
| 23 |
+
|
| 24 |
+
# WebSocket connection manager
|
| 25 |
+
class ConnectionManager:
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self.active_connections: dict = defaultdict(list) # user_id -> list of connections
|
| 28 |
+
|
| 29 |
+
async def connect(self, websocket: WebSocket, user_id: str):
|
| 30 |
+
await websocket.accept()
|
| 31 |
+
self.active_connections[user_id].append(websocket)
|
| 32 |
+
|
| 33 |
+
def disconnect(self, websocket: WebSocket, user_id: str):
|
| 34 |
+
if user_id in self.active_connections:
|
| 35 |
+
self.active_connections[user_id] = [
|
| 36 |
+
conn for conn in self.active_connections[user_id] if conn != websocket
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
async def send_personal_message(self, message: dict, user_id: str):
|
| 40 |
+
if user_id in self.active_connections:
|
| 41 |
+
for connection in self.active_connections[user_id]:
|
| 42 |
+
try:
|
| 43 |
+
await connection.send_text(json.dumps(message))
|
| 44 |
+
except:
|
| 45 |
+
# Remove dead connections
|
| 46 |
+
self.active_connections[user_id].remove(connection)
|
| 47 |
+
|
| 48 |
+
manager = ConnectionManager()
|
| 49 |
+
|
| 50 |
+
# --- HELPER FUNCTIONS ---
|
| 51 |
+
def is_valid_object_id(id_str: str) -> bool:
|
| 52 |
+
try:
|
| 53 |
+
ObjectId(id_str)
|
| 54 |
+
return True
|
| 55 |
+
except:
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
def get_conversation_id(user1_id, user2_id) -> str:
|
| 59 |
+
"""Generate a consistent conversation ID for two users"""
|
| 60 |
+
# Convert both IDs to strings for consistent comparison
|
| 61 |
+
user1_str = str(user1_id)
|
| 62 |
+
user2_str = str(user2_id)
|
| 63 |
+
# Sort IDs to ensure consistent conversation ID regardless of sender/recipient
|
| 64 |
+
sorted_ids = sorted([user1_str, user2_str])
|
| 65 |
+
return f"{sorted_ids[0]}_{sorted_ids[1]}"
|
| 66 |
+
|
| 67 |
+
async def create_notification(
|
| 68 |
+
db_client: AsyncIOMotorClient,
|
| 69 |
+
recipient_id: str,
|
| 70 |
+
title: str,
|
| 71 |
+
message: str,
|
| 72 |
+
notification_type: NotificationType,
|
| 73 |
+
priority: NotificationPriority = NotificationPriority.MEDIUM,
|
| 74 |
+
data: Optional[dict] = None
|
| 75 |
+
):
|
| 76 |
+
"""Create a notification for a user"""
|
| 77 |
+
notification_doc = {
|
| 78 |
+
"recipient_id": ObjectId(recipient_id),
|
| 79 |
+
"title": title,
|
| 80 |
+
"message": message,
|
| 81 |
+
"notification_type": notification_type,
|
| 82 |
+
"priority": priority,
|
| 83 |
+
"data": data or {},
|
| 84 |
+
"is_read": False,
|
| 85 |
+
"created_at": datetime.now()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
result = await db_client.notifications.insert_one(notification_doc)
|
| 89 |
+
notification_doc["_id"] = result.inserted_id
|
| 90 |
+
|
| 91 |
+
# Convert ObjectId to string for WebSocket transmission
|
| 92 |
+
notification_for_ws = {
|
| 93 |
+
"id": str(notification_doc["_id"]),
|
| 94 |
+
"recipient_id": str(notification_doc["recipient_id"]),
|
| 95 |
+
"title": notification_doc["title"],
|
| 96 |
+
"message": notification_doc["message"],
|
| 97 |
+
"notification_type": notification_doc["notification_type"],
|
| 98 |
+
"priority": notification_doc["priority"],
|
| 99 |
+
"data": notification_doc["data"],
|
| 100 |
+
"is_read": notification_doc["is_read"],
|
| 101 |
+
"created_at": notification_doc["created_at"]
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# Send real-time notification via WebSocket
|
| 105 |
+
await manager.send_personal_message({
|
| 106 |
+
"type": "new_notification",
|
| 107 |
+
"data": notification_for_ws
|
| 108 |
+
}, recipient_id)
|
| 109 |
+
|
| 110 |
+
return notification_doc
|
| 111 |
+
|
| 112 |
+
# --- WEBSOCKET ENDPOINT ---
|
| 113 |
+
@router.websocket("/ws/{user_id}")
|
| 114 |
+
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
| 115 |
+
await manager.connect(websocket, user_id)
|
| 116 |
+
print(f"🔌 WebSocket connected for user: {user_id}")
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
while True:
|
| 120 |
+
# Wait for messages from client (keep connection alive)
|
| 121 |
+
data = await websocket.receive_text()
|
| 122 |
+
try:
|
| 123 |
+
message_data = json.loads(data)
|
| 124 |
+
if message_data.get("type") == "ping":
|
| 125 |
+
# Send pong to keep connection alive
|
| 126 |
+
await websocket.send_text(json.dumps({"type": "pong"}))
|
| 127 |
+
except json.JSONDecodeError:
|
| 128 |
+
pass # Ignore invalid JSON
|
| 129 |
+
except WebSocketDisconnect:
|
| 130 |
+
print(f"🔌 WebSocket disconnected for user: {user_id}")
|
| 131 |
+
manager.disconnect(websocket, user_id)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
print(f"❌ WebSocket error for user {user_id}: {e}")
|
| 134 |
+
manager.disconnect(websocket, user_id)
|
| 135 |
+
|
| 136 |
+
# --- CONVERSATION ENDPOINTS ---
|
| 137 |
+
@router.get("/conversations", response_model=ConversationListResponse)
|
| 138 |
+
async def get_conversations(
|
| 139 |
+
page: int = Query(1, ge=1),
|
| 140 |
+
limit: int = Query(20, ge=1, le=100),
|
| 141 |
+
current_user: dict = Depends(get_current_user),
|
| 142 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 143 |
+
):
|
| 144 |
+
"""Get user's conversations"""
|
| 145 |
+
skip = (page - 1) * limit
|
| 146 |
+
user_id = current_user["_id"]
|
| 147 |
+
|
| 148 |
+
# Get all messages where user is sender or recipient
|
| 149 |
+
pipeline = [
|
| 150 |
+
{
|
| 151 |
+
"$match": {
|
| 152 |
+
"$or": [
|
| 153 |
+
{"sender_id": ObjectId(user_id)},
|
| 154 |
+
{"recipient_id": ObjectId(user_id)}
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"$sort": {"created_at": -1}
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"$group": {
|
| 163 |
+
"_id": {
|
| 164 |
+
"$cond": [
|
| 165 |
+
{"$eq": ["$sender_id", ObjectId(user_id)]},
|
| 166 |
+
"$recipient_id",
|
| 167 |
+
"$sender_id"
|
| 168 |
+
]
|
| 169 |
+
},
|
| 170 |
+
"last_message": {"$first": "$$ROOT"},
|
| 171 |
+
"unread_count": {
|
| 172 |
+
"$sum": {
|
| 173 |
+
"$cond": [
|
| 174 |
+
{
|
| 175 |
+
"$and": [
|
| 176 |
+
{"$eq": ["$recipient_id", ObjectId(user_id)]},
|
| 177 |
+
{"$ne": ["$status", "read"]}
|
| 178 |
+
]
|
| 179 |
+
},
|
| 180 |
+
1,
|
| 181 |
+
0
|
| 182 |
+
]
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"$sort": {"last_message.created_at": -1}
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"$skip": skip
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"$limit": limit
|
| 195 |
+
}
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
conversations_data = await db_client.messages.aggregate(pipeline).to_list(length=limit)
|
| 199 |
+
|
| 200 |
+
# Get user details for each conversation
|
| 201 |
+
conversations = []
|
| 202 |
+
for conv_data in conversations_data:
|
| 203 |
+
other_user_id = str(conv_data["_id"])
|
| 204 |
+
other_user = await db_client.users.find_one({"_id": conv_data["_id"]})
|
| 205 |
+
|
| 206 |
+
if other_user:
|
| 207 |
+
# Convert user_id to string for consistent comparison
|
| 208 |
+
user_id_str = str(user_id)
|
| 209 |
+
conversation_id = get_conversation_id(user_id_str, other_user_id)
|
| 210 |
+
|
| 211 |
+
# Build last message response
|
| 212 |
+
last_message = None
|
| 213 |
+
if conv_data["last_message"]:
|
| 214 |
+
last_message = MessageResponse(
|
| 215 |
+
id=str(conv_data["last_message"]["_id"]),
|
| 216 |
+
sender_id=str(conv_data["last_message"]["sender_id"]),
|
| 217 |
+
recipient_id=str(conv_data["last_message"]["recipient_id"]),
|
| 218 |
+
sender_name=current_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else other_user["full_name"],
|
| 219 |
+
recipient_name=other_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else current_user["full_name"],
|
| 220 |
+
content=conv_data["last_message"]["content"],
|
| 221 |
+
message_type=conv_data["last_message"]["message_type"],
|
| 222 |
+
attachment_url=conv_data["last_message"].get("attachment_url"),
|
| 223 |
+
reply_to_message_id=str(conv_data["last_message"]["reply_to_message_id"]) if conv_data["last_message"].get("reply_to_message_id") else None,
|
| 224 |
+
status=conv_data["last_message"]["status"],
|
| 225 |
+
is_archived=conv_data["last_message"].get("is_archived", False),
|
| 226 |
+
created_at=conv_data["last_message"]["created_at"],
|
| 227 |
+
updated_at=conv_data["last_message"]["updated_at"],
|
| 228 |
+
read_at=conv_data["last_message"].get("read_at")
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
conversations.append(ConversationResponse(
|
| 232 |
+
id=conversation_id,
|
| 233 |
+
participant_ids=[user_id_str, other_user_id],
|
| 234 |
+
participant_names=[current_user["full_name"], other_user["full_name"]],
|
| 235 |
+
last_message=last_message,
|
| 236 |
+
unread_count=conv_data["unread_count"],
|
| 237 |
+
created_at=conv_data["last_message"]["created_at"] if conv_data["last_message"] else datetime.now(),
|
| 238 |
+
updated_at=conv_data["last_message"]["updated_at"] if conv_data["last_message"] else datetime.now()
|
| 239 |
+
))
|
| 240 |
+
|
| 241 |
+
# Get total count
|
| 242 |
+
total_pipeline = [
|
| 243 |
+
{
|
| 244 |
+
"$match": {
|
| 245 |
+
"$or": [
|
| 246 |
+
{"sender_id": ObjectId(user_id)},
|
| 247 |
+
{"recipient_id": ObjectId(user_id)}
|
| 248 |
+
]
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"$group": {
|
| 253 |
+
"_id": {
|
| 254 |
+
"$cond": [
|
| 255 |
+
{"$eq": ["$sender_id", ObjectId(user_id)]},
|
| 256 |
+
"$recipient_id",
|
| 257 |
+
"$sender_id"
|
| 258 |
+
]
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"$count": "total"
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
total_result = await db_client.messages.aggregate(total_pipeline).to_list(length=1)
|
| 268 |
+
total = total_result[0]["total"] if total_result else 0
|
| 269 |
+
|
| 270 |
+
return ConversationListResponse(
|
| 271 |
+
conversations=conversations,
|
| 272 |
+
total=total,
|
| 273 |
+
page=page,
|
| 274 |
+
limit=limit
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
# --- MESSAGE ENDPOINTS ---
|
| 278 |
+
@router.post("/messages", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
| 279 |
+
async def send_message(
|
| 280 |
+
message_data: MessageCreate,
|
| 281 |
+
current_user: dict = Depends(get_current_user),
|
| 282 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 283 |
+
):
|
| 284 |
+
"""Send a message to another user"""
|
| 285 |
+
if not is_valid_object_id(message_data.recipient_id):
|
| 286 |
+
raise HTTPException(
|
| 287 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 288 |
+
detail="Invalid recipient ID"
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# Check if recipient exists
|
| 292 |
+
recipient = await db_client.users.find_one({"_id": ObjectId(message_data.recipient_id)})
|
| 293 |
+
if not recipient:
|
| 294 |
+
raise HTTPException(
|
| 295 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 296 |
+
detail="Recipient not found"
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Check if user can message this recipient
|
| 300 |
+
# Patients can only message their doctors, doctors can message their patients
|
| 301 |
+
current_user_roles = current_user.get('roles', [])
|
| 302 |
+
if isinstance(current_user.get('role'), str):
|
| 303 |
+
current_user_roles.append(current_user.get('role'))
|
| 304 |
+
|
| 305 |
+
recipient_roles = recipient.get('roles', [])
|
| 306 |
+
if isinstance(recipient.get('role'), str):
|
| 307 |
+
recipient_roles.append(recipient.get('role'))
|
| 308 |
+
|
| 309 |
+
if 'patient' in current_user_roles:
|
| 310 |
+
# Patients can only message doctors
|
| 311 |
+
if 'doctor' not in recipient_roles:
|
| 312 |
+
raise HTTPException(
|
| 313 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 314 |
+
detail="Patients can only message doctors"
|
| 315 |
+
)
|
| 316 |
+
elif 'doctor' in current_user_roles:
|
| 317 |
+
# Doctors can only message their patients
|
| 318 |
+
if 'patient' not in recipient_roles:
|
| 319 |
+
raise HTTPException(
|
| 320 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 321 |
+
detail="Doctors can only message patients"
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Check reply message if provided
|
| 325 |
+
if message_data.reply_to_message_id:
|
| 326 |
+
if not is_valid_object_id(message_data.reply_to_message_id):
|
| 327 |
+
raise HTTPException(
|
| 328 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 329 |
+
detail="Invalid reply message ID"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
reply_message = await db_client.messages.find_one({"_id": ObjectId(message_data.reply_to_message_id)})
|
| 333 |
+
if not reply_message:
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 336 |
+
detail="Reply message not found"
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Create message
|
| 340 |
+
message_doc = {
|
| 341 |
+
"sender_id": ObjectId(current_user["_id"]),
|
| 342 |
+
"recipient_id": ObjectId(message_data.recipient_id),
|
| 343 |
+
"content": message_data.content,
|
| 344 |
+
"message_type": message_data.message_type,
|
| 345 |
+
"attachment_url": message_data.attachment_url,
|
| 346 |
+
"reply_to_message_id": ObjectId(message_data.reply_to_message_id) if message_data.reply_to_message_id else None,
|
| 347 |
+
"status": MessageStatus.SENT,
|
| 348 |
+
"is_archived": False,
|
| 349 |
+
"created_at": datetime.now(),
|
| 350 |
+
"updated_at": datetime.now()
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
result = await db_client.messages.insert_one(message_doc)
|
| 354 |
+
message_doc["_id"] = result.inserted_id
|
| 355 |
+
|
| 356 |
+
# Send real-time message via WebSocket
|
| 357 |
+
await manager.send_personal_message({
|
| 358 |
+
"type": "new_message",
|
| 359 |
+
"data": {
|
| 360 |
+
"id": str(message_doc["_id"]),
|
| 361 |
+
"sender_id": str(message_doc["sender_id"]),
|
| 362 |
+
"recipient_id": str(message_doc["recipient_id"]),
|
| 363 |
+
"sender_name": current_user["full_name"],
|
| 364 |
+
"recipient_name": recipient["full_name"],
|
| 365 |
+
"content": message_doc["content"],
|
| 366 |
+
"message_type": message_doc["message_type"],
|
| 367 |
+
"attachment_url": message_doc["attachment_url"],
|
| 368 |
+
"reply_to_message_id": str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
|
| 369 |
+
"status": message_doc["status"],
|
| 370 |
+
"is_archived": message_doc["is_archived"],
|
| 371 |
+
"created_at": message_doc["created_at"],
|
| 372 |
+
"updated_at": message_doc["updated_at"]
|
| 373 |
+
}
|
| 374 |
+
}, message_data.recipient_id)
|
| 375 |
+
|
| 376 |
+
# Create notification for recipient
|
| 377 |
+
await create_notification(
|
| 378 |
+
db_client,
|
| 379 |
+
message_data.recipient_id,
|
| 380 |
+
f"New message from {current_user['full_name']}",
|
| 381 |
+
message_data.content[:100] + "..." if len(message_data.content) > 100 else message_data.content,
|
| 382 |
+
NotificationType.MESSAGE,
|
| 383 |
+
NotificationPriority.MEDIUM,
|
| 384 |
+
{"message_id": str(message_doc["_id"]), "sender_id": str(current_user["_id"])}
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
return MessageResponse(
|
| 388 |
+
id=str(message_doc["_id"]),
|
| 389 |
+
sender_id=str(message_doc["sender_id"]),
|
| 390 |
+
recipient_id=str(message_doc["recipient_id"]),
|
| 391 |
+
sender_name=current_user["full_name"],
|
| 392 |
+
recipient_name=recipient["full_name"],
|
| 393 |
+
content=message_doc["content"],
|
| 394 |
+
message_type=message_doc["message_type"],
|
| 395 |
+
attachment_url=message_doc["attachment_url"],
|
| 396 |
+
reply_to_message_id=str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
|
| 397 |
+
status=message_doc["status"],
|
| 398 |
+
is_archived=message_doc["is_archived"],
|
| 399 |
+
created_at=message_doc["created_at"],
|
| 400 |
+
updated_at=message_doc["updated_at"]
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
@router.get("/messages/{conversation_id}", response_model=MessageListResponse)
|
| 404 |
+
async def get_messages(
|
| 405 |
+
conversation_id: str,
|
| 406 |
+
page: int = Query(1, ge=1),
|
| 407 |
+
limit: int = Query(50, ge=1, le=100),
|
| 408 |
+
current_user: dict = Depends(get_current_user),
|
| 409 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 410 |
+
):
|
| 411 |
+
"""Get messages for a specific conversation"""
|
| 412 |
+
skip = (page - 1) * limit
|
| 413 |
+
user_id = current_user["_id"]
|
| 414 |
+
|
| 415 |
+
# Parse conversation ID to get the other participant
|
| 416 |
+
try:
|
| 417 |
+
participant_ids = conversation_id.split("_")
|
| 418 |
+
if len(participant_ids) != 2:
|
| 419 |
+
raise HTTPException(
|
| 420 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 421 |
+
detail="Invalid conversation ID format"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Find the other participant
|
| 425 |
+
other_user_id = None
|
| 426 |
+
user_id_str = str(user_id)
|
| 427 |
+
for pid in participant_ids:
|
| 428 |
+
if pid != user_id_str:
|
| 429 |
+
other_user_id = pid
|
| 430 |
+
break
|
| 431 |
+
|
| 432 |
+
if not other_user_id or not is_valid_object_id(other_user_id):
|
| 433 |
+
raise HTTPException(
|
| 434 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 435 |
+
detail="Invalid conversation ID"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# Verify the other user exists
|
| 439 |
+
other_user = await db_client.users.find_one({"_id": ObjectId(other_user_id)})
|
| 440 |
+
if not other_user:
|
| 441 |
+
raise HTTPException(
|
| 442 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 443 |
+
detail="Conversation participant not found"
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
except Exception as e:
|
| 447 |
+
raise HTTPException(
|
| 448 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 449 |
+
detail="Invalid conversation ID"
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
# Get messages between the two users
|
| 453 |
+
filter_query = {
|
| 454 |
+
"$or": [
|
| 455 |
+
{
|
| 456 |
+
"sender_id": ObjectId(user_id),
|
| 457 |
+
"recipient_id": ObjectId(other_user_id)
|
| 458 |
+
},
|
| 459 |
+
{
|
| 460 |
+
"sender_id": ObjectId(other_user_id),
|
| 461 |
+
"recipient_id": ObjectId(user_id)
|
| 462 |
+
}
|
| 463 |
+
]
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
# Get messages
|
| 467 |
+
cursor = db_client.messages.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
|
| 468 |
+
messages = await cursor.to_list(length=limit)
|
| 469 |
+
|
| 470 |
+
# Mark messages as read
|
| 471 |
+
unread_messages = [
|
| 472 |
+
msg["_id"] for msg in messages
|
| 473 |
+
if msg["recipient_id"] == ObjectId(user_id) and msg["status"] != "read"
|
| 474 |
+
]
|
| 475 |
+
|
| 476 |
+
if unread_messages:
|
| 477 |
+
await db_client.messages.update_many(
|
| 478 |
+
{"_id": {"$in": unread_messages}},
|
| 479 |
+
{"$set": {"status": "read", "read_at": datetime.now()}}
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
# Get total count
|
| 483 |
+
total = await db_client.messages.count_documents(filter_query)
|
| 484 |
+
|
| 485 |
+
# Build message responses
|
| 486 |
+
message_responses = []
|
| 487 |
+
for msg in messages:
|
| 488 |
+
sender = await db_client.users.find_one({"_id": msg["sender_id"]})
|
| 489 |
+
recipient = await db_client.users.find_one({"_id": msg["recipient_id"]})
|
| 490 |
+
|
| 491 |
+
message_responses.append(MessageResponse(
|
| 492 |
+
id=str(msg["_id"]),
|
| 493 |
+
sender_id=str(msg["sender_id"]),
|
| 494 |
+
recipient_id=str(msg["recipient_id"]),
|
| 495 |
+
sender_name=sender["full_name"] if sender else "Unknown User",
|
| 496 |
+
recipient_name=recipient["full_name"] if recipient else "Unknown User",
|
| 497 |
+
content=msg["content"],
|
| 498 |
+
message_type=msg["message_type"],
|
| 499 |
+
attachment_url=msg.get("attachment_url"),
|
| 500 |
+
reply_to_message_id=str(msg["reply_to_message_id"]) if msg.get("reply_to_message_id") else None,
|
| 501 |
+
status=msg["status"],
|
| 502 |
+
is_archived=msg.get("is_archived", False),
|
| 503 |
+
created_at=msg["created_at"],
|
| 504 |
+
updated_at=msg["updated_at"],
|
| 505 |
+
read_at=msg.get("read_at")
|
| 506 |
+
))
|
| 507 |
+
|
| 508 |
+
return MessageListResponse(
|
| 509 |
+
messages=message_responses,
|
| 510 |
+
total=total,
|
| 511 |
+
page=page,
|
| 512 |
+
limit=limit,
|
| 513 |
+
conversation_id=conversation_id
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
@router.put("/messages/{message_id}", response_model=MessageResponse)
|
| 517 |
+
async def update_message(
|
| 518 |
+
message_id: str,
|
| 519 |
+
message_data: MessageUpdate,
|
| 520 |
+
current_user: dict = Depends(get_current_user),
|
| 521 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 522 |
+
):
|
| 523 |
+
"""Update a message (only sender can update)"""
|
| 524 |
+
if not is_valid_object_id(message_id):
|
| 525 |
+
raise HTTPException(
|
| 526 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 527 |
+
detail="Invalid message ID"
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 531 |
+
if not message:
|
| 532 |
+
raise HTTPException(
|
| 533 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 534 |
+
detail="Message not found"
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
# Only sender can update message
|
| 538 |
+
if message["sender_id"] != ObjectId(current_user["_id"]):
|
| 539 |
+
raise HTTPException(
|
| 540 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 541 |
+
detail="You can only update your own messages"
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
# Build update data
|
| 545 |
+
update_data = {"updated_at": datetime.now()}
|
| 546 |
+
|
| 547 |
+
if message_data.content is not None:
|
| 548 |
+
update_data["content"] = message_data.content
|
| 549 |
+
if message_data.is_archived is not None:
|
| 550 |
+
update_data["is_archived"] = message_data.is_archived
|
| 551 |
+
|
| 552 |
+
# Update message
|
| 553 |
+
await db_client.messages.update_one(
|
| 554 |
+
{"_id": ObjectId(message_id)},
|
| 555 |
+
{"$set": update_data}
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Get updated message
|
| 559 |
+
updated_message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 560 |
+
|
| 561 |
+
# Get sender and recipient names
|
| 562 |
+
sender = await db_client.users.find_one({"_id": updated_message["sender_id"]})
|
| 563 |
+
recipient = await db_client.users.find_one({"_id": updated_message["recipient_id"]})
|
| 564 |
+
|
| 565 |
+
return MessageResponse(
|
| 566 |
+
id=str(updated_message["_id"]),
|
| 567 |
+
sender_id=str(updated_message["sender_id"]),
|
| 568 |
+
recipient_id=str(updated_message["recipient_id"]),
|
| 569 |
+
sender_name=sender["full_name"] if sender else "Unknown User",
|
| 570 |
+
recipient_name=recipient["full_name"] if recipient else "Unknown User",
|
| 571 |
+
content=updated_message["content"],
|
| 572 |
+
message_type=updated_message["message_type"],
|
| 573 |
+
attachment_url=updated_message.get("attachment_url"),
|
| 574 |
+
reply_to_message_id=str(updated_message["reply_to_message_id"]) if updated_message.get("reply_to_message_id") else None,
|
| 575 |
+
status=updated_message["status"],
|
| 576 |
+
is_archived=updated_message.get("is_archived", False),
|
| 577 |
+
created_at=updated_message["created_at"],
|
| 578 |
+
updated_at=updated_message["updated_at"],
|
| 579 |
+
read_at=updated_message.get("read_at")
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
@router.delete("/messages/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 583 |
+
async def delete_message(
|
| 584 |
+
message_id: str,
|
| 585 |
+
current_user: dict = Depends(get_current_user),
|
| 586 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 587 |
+
):
|
| 588 |
+
"""Delete a message (only sender can delete)"""
|
| 589 |
+
if not is_valid_object_id(message_id):
|
| 590 |
+
raise HTTPException(
|
| 591 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 592 |
+
detail="Invalid message ID"
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 596 |
+
if not message:
|
| 597 |
+
raise HTTPException(
|
| 598 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 599 |
+
detail="Message not found"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# Only sender can delete message
|
| 603 |
+
if message["sender_id"] != ObjectId(current_user["_id"]):
|
| 604 |
+
raise HTTPException(
|
| 605 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 606 |
+
detail="You can only delete your own messages"
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
await db_client.messages.delete_one({"_id": ObjectId(message_id)})
|
| 610 |
+
|
| 611 |
+
# --- NOTIFICATION ENDPOINTS ---
|
| 612 |
+
@router.get("/notifications", response_model=NotificationListResponse)
|
| 613 |
+
async def get_notifications(
|
| 614 |
+
page: int = Query(1, ge=1),
|
| 615 |
+
limit: int = Query(20, ge=1, le=100),
|
| 616 |
+
unread_only: bool = Query(False),
|
| 617 |
+
current_user: dict = Depends(get_current_user),
|
| 618 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 619 |
+
):
|
| 620 |
+
"""Get user's notifications"""
|
| 621 |
+
skip = (page - 1) * limit
|
| 622 |
+
user_id = current_user["_id"]
|
| 623 |
+
|
| 624 |
+
# Build filter
|
| 625 |
+
filter_query = {"recipient_id": ObjectId(user_id)}
|
| 626 |
+
if unread_only:
|
| 627 |
+
filter_query["is_read"] = False
|
| 628 |
+
|
| 629 |
+
# Get notifications
|
| 630 |
+
cursor = db_client.notifications.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
|
| 631 |
+
notifications = await cursor.to_list(length=limit)
|
| 632 |
+
|
| 633 |
+
# Get total count and unread count
|
| 634 |
+
total = await db_client.notifications.count_documents(filter_query)
|
| 635 |
+
unread_count = await db_client.notifications.count_documents({
|
| 636 |
+
"recipient_id": ObjectId(user_id),
|
| 637 |
+
"is_read": False
|
| 638 |
+
})
|
| 639 |
+
|
| 640 |
+
# Build notification responses
|
| 641 |
+
notification_responses = []
|
| 642 |
+
for notif in notifications:
|
| 643 |
+
# Convert any ObjectId fields in data to strings
|
| 644 |
+
data = notif.get("data", {})
|
| 645 |
+
if data:
|
| 646 |
+
# Convert ObjectId fields to strings
|
| 647 |
+
for key, value in data.items():
|
| 648 |
+
if isinstance(value, ObjectId):
|
| 649 |
+
data[key] = str(value)
|
| 650 |
+
|
| 651 |
+
notification_responses.append(NotificationResponse(
|
| 652 |
+
id=str(notif["_id"]),
|
| 653 |
+
recipient_id=str(notif["recipient_id"]),
|
| 654 |
+
recipient_name=current_user["full_name"],
|
| 655 |
+
title=notif["title"],
|
| 656 |
+
message=notif["message"],
|
| 657 |
+
notification_type=notif["notification_type"],
|
| 658 |
+
priority=notif["priority"],
|
| 659 |
+
data=data,
|
| 660 |
+
is_read=notif.get("is_read", False),
|
| 661 |
+
created_at=notif["created_at"],
|
| 662 |
+
read_at=notif.get("read_at")
|
| 663 |
+
))
|
| 664 |
+
|
| 665 |
+
return NotificationListResponse(
|
| 666 |
+
notifications=notification_responses,
|
| 667 |
+
total=total,
|
| 668 |
+
unread_count=unread_count,
|
| 669 |
+
page=page,
|
| 670 |
+
limit=limit
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
@router.put("/notifications/{notification_id}/read", response_model=NotificationResponse)
|
| 674 |
+
async def mark_notification_read(
|
| 675 |
+
notification_id: str,
|
| 676 |
+
current_user: dict = Depends(get_current_user),
|
| 677 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 678 |
+
):
|
| 679 |
+
"""Mark a notification as read"""
|
| 680 |
+
if not is_valid_object_id(notification_id):
|
| 681 |
+
raise HTTPException(
|
| 682 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 683 |
+
detail="Invalid notification ID"
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
|
| 687 |
+
if not notification:
|
| 688 |
+
raise HTTPException(
|
| 689 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 690 |
+
detail="Notification not found"
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
# Only recipient can mark as read
|
| 694 |
+
if notification["recipient_id"] != ObjectId(current_user["_id"]):
|
| 695 |
+
raise HTTPException(
|
| 696 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 697 |
+
detail="You can only mark your own notifications as read"
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
# Update notification
|
| 701 |
+
await db_client.notifications.update_one(
|
| 702 |
+
{"_id": ObjectId(notification_id)},
|
| 703 |
+
{"$set": {"is_read": True, "read_at": datetime.now()}}
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
# Get updated notification
|
| 707 |
+
updated_notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
|
| 708 |
+
|
| 709 |
+
# Convert any ObjectId fields in data to strings
|
| 710 |
+
data = updated_notification.get("data", {})
|
| 711 |
+
if data:
|
| 712 |
+
# Convert ObjectId fields to strings
|
| 713 |
+
for key, value in data.items():
|
| 714 |
+
if isinstance(value, ObjectId):
|
| 715 |
+
data[key] = str(value)
|
| 716 |
+
|
| 717 |
+
return NotificationResponse(
|
| 718 |
+
id=str(updated_notification["_id"]),
|
| 719 |
+
recipient_id=str(updated_notification["recipient_id"]),
|
| 720 |
+
recipient_name=current_user["full_name"],
|
| 721 |
+
title=updated_notification["title"],
|
| 722 |
+
message=updated_notification["message"],
|
| 723 |
+
notification_type=updated_notification["notification_type"],
|
| 724 |
+
priority=updated_notification["priority"],
|
| 725 |
+
data=data,
|
| 726 |
+
is_read=updated_notification.get("is_read", False),
|
| 727 |
+
created_at=updated_notification["created_at"],
|
| 728 |
+
read_at=updated_notification.get("read_at")
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
@router.put("/notifications/read-all", status_code=status.HTTP_200_OK)
|
| 732 |
+
async def mark_all_notifications_read(
|
| 733 |
+
current_user: dict = Depends(get_current_user),
|
| 734 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 735 |
+
):
|
| 736 |
+
"""Mark all user's notifications as read"""
|
| 737 |
+
user_id = current_user["_id"]
|
| 738 |
+
|
| 739 |
+
await db_client.notifications.update_many(
|
| 740 |
+
{
|
| 741 |
+
"recipient_id": ObjectId(user_id),
|
| 742 |
+
"is_read": False
|
| 743 |
+
},
|
| 744 |
+
{
|
| 745 |
+
"$set": {
|
| 746 |
+
"is_read": True,
|
| 747 |
+
"read_at": datetime.now()
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
return {"message": "All notifications marked as read"}
|
| 753 |
+
|
| 754 |
+
# --- FILE UPLOAD ENDPOINT ---
|
| 755 |
+
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
| 756 |
+
async def upload_file(
|
| 757 |
+
file: UploadFile = File(...),
|
| 758 |
+
current_user: dict = Depends(get_current_user)
|
| 759 |
+
):
|
| 760 |
+
"""Upload a file for messaging"""
|
| 761 |
+
|
| 762 |
+
# Validate file type
|
| 763 |
+
allowed_types = {
|
| 764 |
+
'image': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'],
|
| 765 |
+
'document': ['.pdf', '.doc', '.docx', '.txt', '.rtf'],
|
| 766 |
+
'spreadsheet': ['.xls', '.xlsx', '.csv'],
|
| 767 |
+
'presentation': ['.ppt', '.pptx'],
|
| 768 |
+
'archive': ['.zip', '.rar', '.7z']
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
# Get file extension
|
| 772 |
+
file_ext = Path(file.filename).suffix.lower()
|
| 773 |
+
|
| 774 |
+
# Check if file type is allowed
|
| 775 |
+
is_allowed = False
|
| 776 |
+
file_category = None
|
| 777 |
+
for category, extensions in allowed_types.items():
|
| 778 |
+
if file_ext in extensions:
|
| 779 |
+
is_allowed = True
|
| 780 |
+
file_category = category
|
| 781 |
+
break
|
| 782 |
+
|
| 783 |
+
if not is_allowed:
|
| 784 |
+
raise HTTPException(
|
| 785 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 786 |
+
detail=f"File type {file_ext} is not allowed. Allowed types: {', '.join([ext for exts in allowed_types.values() for ext in exts])}"
|
| 787 |
+
)
|
| 788 |
+
|
| 789 |
+
# Check file size (max 10MB)
|
| 790 |
+
max_size = 10 * 1024 * 1024 # 10MB
|
| 791 |
+
if file.size and file.size > max_size:
|
| 792 |
+
raise HTTPException(
|
| 793 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 794 |
+
detail=f"File size exceeds maximum limit of 10MB"
|
| 795 |
+
)
|
| 796 |
+
|
| 797 |
+
# Create uploads directory if it doesn't exist
|
| 798 |
+
try:
|
| 799 |
+
upload_dir = Path("uploads")
|
| 800 |
+
upload_dir.mkdir(exist_ok=True)
|
| 801 |
+
except PermissionError:
|
| 802 |
+
# In containerized environments, use temp directory
|
| 803 |
+
import tempfile
|
| 804 |
+
upload_dir = Path(tempfile.gettempdir()) / "uploads"
|
| 805 |
+
upload_dir.mkdir(exist_ok=True)
|
| 806 |
+
|
| 807 |
+
# Create category subdirectory
|
| 808 |
+
category_dir = upload_dir / file_category
|
| 809 |
+
category_dir.mkdir(exist_ok=True)
|
| 810 |
+
|
| 811 |
+
# Generate unique filename
|
| 812 |
+
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
| 813 |
+
file_path = category_dir / unique_filename
|
| 814 |
+
|
| 815 |
+
try:
|
| 816 |
+
# Save file
|
| 817 |
+
with open(file_path, "wb") as buffer:
|
| 818 |
+
content = await file.read()
|
| 819 |
+
buffer.write(content)
|
| 820 |
+
|
| 821 |
+
# Return file info
|
| 822 |
+
return {
|
| 823 |
+
"filename": file.filename,
|
| 824 |
+
"file_url": f"/uploads/{file_category}/{unique_filename}",
|
| 825 |
+
"file_size": len(content),
|
| 826 |
+
"file_type": file_category,
|
| 827 |
+
"message_type": MessageType.IMAGE if file_category == 'image' else MessageType.FILE
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
except Exception as e:
|
| 831 |
+
# Clean up file if save fails
|
| 832 |
+
if file_path.exists():
|
| 833 |
+
file_path.unlink()
|
| 834 |
+
raise HTTPException(
|
| 835 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 836 |
+
detail=f"Failed to upload file: {str(e)}"
|
| 837 |
+
)
|
| 838 |
+
|
| 839 |
+
# --- STATIC FILE SERVING ---
|
| 840 |
+
@router.get("/uploads/{category}/{filename}")
|
| 841 |
+
async def serve_file(category: str, filename: str):
|
| 842 |
+
"""Serve uploaded files"""
|
| 843 |
+
import os
|
| 844 |
+
|
| 845 |
+
# Get the current working directory and construct absolute path
|
| 846 |
+
current_dir = os.getcwd()
|
| 847 |
+
file_path = Path(current_dir) / "uploads" / category / filename
|
| 848 |
+
|
| 849 |
+
print(f"🔍 Looking for file: {file_path}")
|
| 850 |
+
print(f"📁 File exists: {file_path.exists()}")
|
| 851 |
+
|
| 852 |
+
if not file_path.exists():
|
| 853 |
+
raise HTTPException(
|
| 854 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 855 |
+
detail=f"File not found: {file_path}"
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
# Determine content type based on file extension
|
| 859 |
+
ext = file_path.suffix.lower()
|
| 860 |
+
content_types = {
|
| 861 |
+
'.jpg': 'image/jpeg',
|
| 862 |
+
'.jpeg': 'image/jpeg',
|
| 863 |
+
'.png': 'image/png',
|
| 864 |
+
'.gif': 'image/gif',
|
| 865 |
+
'.bmp': 'image/bmp',
|
| 866 |
+
'.webp': 'image/webp',
|
| 867 |
+
'.pdf': 'application/pdf',
|
| 868 |
+
'.doc': 'application/msword',
|
| 869 |
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 870 |
+
'.txt': 'text/plain',
|
| 871 |
+
'.rtf': 'application/rtf',
|
| 872 |
+
'.xls': 'application/vnd.ms-excel',
|
| 873 |
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 874 |
+
'.csv': 'text/csv',
|
| 875 |
+
'.ppt': 'application/vnd.ms-powerpoint',
|
| 876 |
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
| 877 |
+
'.zip': 'application/zip',
|
| 878 |
+
'.rar': 'application/x-rar-compressed',
|
| 879 |
+
'.7z': 'application/x-7z-compressed'
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
content_type = content_types.get(ext, 'application/octet-stream')
|
| 883 |
+
|
| 884 |
+
try:
|
| 885 |
+
# Read and return file content
|
| 886 |
+
with open(file_path, "rb") as f:
|
| 887 |
+
content = f.read()
|
| 888 |
+
|
| 889 |
+
from fastapi.responses import Response
|
| 890 |
+
return Response(
|
| 891 |
+
content=content,
|
| 892 |
+
media_type=content_type,
|
| 893 |
+
headers={
|
| 894 |
+
"Content-Disposition": f"inline; filename={filename}",
|
| 895 |
+
"Cache-Control": "public, max-age=31536000"
|
| 896 |
+
}
|
| 897 |
+
)
|
| 898 |
+
except Exception as e:
|
| 899 |
+
print(f"❌ Error reading file: {e}")
|
| 900 |
+
raise HTTPException(
|
| 901 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 902 |
+
detail=f"Error reading file: {str(e)}"
|
| 903 |
+
)
|
deployment/routes/patients.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Query, status, Body
|
| 2 |
+
from db.mongo import patients_collection, db # Added db import
|
| 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, PatientCreate
|
| 7 |
+
from models.schemas import PatientListResponse # Fixed import
|
| 8 |
+
from api.services.fhir_integration import HAPIFHIRIntegrationService
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from bson import ObjectId
|
| 11 |
+
from bson.errors import InvalidId
|
| 12 |
+
from typing import Optional, List, Dict, Any
|
| 13 |
+
from pymongo import UpdateOne, DeleteOne
|
| 14 |
+
from pymongo.errors import BulkWriteError
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
import glob
|
| 18 |
+
import uuid
|
| 19 |
+
import re
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
import os
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 25 |
+
|
| 26 |
+
# Configure logging
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.INFO,
|
| 29 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
router = APIRouter()
|
| 34 |
+
|
| 35 |
+
# Configuration
|
| 36 |
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 37 |
+
SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
|
| 38 |
+
try:
|
| 39 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 40 |
+
except PermissionError:
|
| 41 |
+
# In containerized environments, we might not have write permissions
|
| 42 |
+
# Use a temporary directory instead
|
| 43 |
+
import tempfile
|
| 44 |
+
SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
|
| 45 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
# Pydantic models for update validation
|
| 48 |
+
class ConditionUpdate(BaseModel):
|
| 49 |
+
id: Optional[str] = None
|
| 50 |
+
code: Optional[str] = None
|
| 51 |
+
status: Optional[str] = None
|
| 52 |
+
onset_date: Optional[str] = None
|
| 53 |
+
recorded_date: Optional[str] = None
|
| 54 |
+
verification_status: Optional[str] = None
|
| 55 |
+
notes: Optional[str] = None
|
| 56 |
+
|
| 57 |
+
class MedicationUpdate(BaseModel):
|
| 58 |
+
id: Optional[str] = None
|
| 59 |
+
name: Optional[str] = None
|
| 60 |
+
status: Optional[str] = None
|
| 61 |
+
prescribed_date: Optional[str] = None
|
| 62 |
+
requester: Optional[str] = None
|
| 63 |
+
dosage: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
class EncounterUpdate(BaseModel):
|
| 66 |
+
id: Optional[str] = None
|
| 67 |
+
type: Optional[str] = None
|
| 68 |
+
status: Optional[str] = None
|
| 69 |
+
period: Optional[Dict[str, str]] = None
|
| 70 |
+
service_provider: Optional[str] = None
|
| 71 |
+
|
| 72 |
+
class NoteUpdate(BaseModel):
|
| 73 |
+
id: Optional[str] = None
|
| 74 |
+
title: Optional[str] = None
|
| 75 |
+
date: Optional[str] = None
|
| 76 |
+
author: Optional[str] = None
|
| 77 |
+
content: Optional[str] = None
|
| 78 |
+
|
| 79 |
+
class PatientUpdate(BaseModel):
|
| 80 |
+
full_name: Optional[str] = None
|
| 81 |
+
gender: Optional[str] = None
|
| 82 |
+
date_of_birth: Optional[str] = None
|
| 83 |
+
address: Optional[str] = None
|
| 84 |
+
city: Optional[str] = None
|
| 85 |
+
state: Optional[str] = None
|
| 86 |
+
postal_code: Optional[str] = None
|
| 87 |
+
country: Optional[str] = None
|
| 88 |
+
marital_status: Optional[str] = None
|
| 89 |
+
language: Optional[str] = None
|
| 90 |
+
conditions: Optional[List[ConditionUpdate]] = None
|
| 91 |
+
medications: Optional[List[MedicationUpdate]] = None
|
| 92 |
+
encounters: Optional[List[EncounterUpdate]] = None
|
| 93 |
+
notes: Optional[List[NoteUpdate]] = None
|
| 94 |
+
|
| 95 |
+
@router.get("/debug/count")
|
| 96 |
+
async def debug_patient_count():
|
| 97 |
+
"""Debug endpoint to verify patient counts"""
|
| 98 |
+
try:
|
| 99 |
+
total = await patients_collection.count_documents({})
|
| 100 |
+
synthea = await patients_collection.count_documents({"source": "synthea"})
|
| 101 |
+
manual = await patients_collection.count_documents({"source": "manual"})
|
| 102 |
+
return {
|
| 103 |
+
"total": total,
|
| 104 |
+
"synthea": synthea,
|
| 105 |
+
"manual": manual,
|
| 106 |
+
"message": f"Found {total} total patients ({synthea} from synthea, {manual} manual)"
|
| 107 |
+
}
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error counting patients: {str(e)}")
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 112 |
+
detail=f"Error counting patients: {str(e)}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
@router.post("/patients", status_code=status.HTTP_201_CREATED)
|
| 116 |
+
async def create_patient(
|
| 117 |
+
patient_data: PatientCreate,
|
| 118 |
+
current_user: dict = Depends(get_current_user)
|
| 119 |
+
):
|
| 120 |
+
"""Create a new patient in the database"""
|
| 121 |
+
logger.info(f"Creating new patient by user {current_user.get('email')}")
|
| 122 |
+
|
| 123 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 124 |
+
logger.warning(f"Unauthorized create attempt by {current_user.get('email')}")
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 127 |
+
detail="Only administrators and doctors can create patients"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
# Prepare the patient document
|
| 132 |
+
patient_doc = patient_data.dict()
|
| 133 |
+
now = datetime.utcnow().isoformat()
|
| 134 |
+
|
| 135 |
+
# Add system-generated fields
|
| 136 |
+
patient_doc.update({
|
| 137 |
+
"fhir_id": str(uuid.uuid4()),
|
| 138 |
+
"import_date": now,
|
| 139 |
+
"last_updated": now,
|
| 140 |
+
"source": "manual",
|
| 141 |
+
"created_by": current_user.get('email')
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
# Ensure arrays exist even if empty
|
| 145 |
+
for field in ['conditions', 'medications', 'encounters', 'notes']:
|
| 146 |
+
if field not in patient_doc:
|
| 147 |
+
patient_doc[field] = []
|
| 148 |
+
|
| 149 |
+
# Insert the patient document
|
| 150 |
+
result = await patients_collection.insert_one(patient_doc)
|
| 151 |
+
|
| 152 |
+
# Return the created patient with the generated ID
|
| 153 |
+
created_patient = await patients_collection.find_one(
|
| 154 |
+
{"_id": result.inserted_id}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
if not created_patient:
|
| 158 |
+
logger.error("Failed to retrieve created patient")
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 161 |
+
detail="Failed to retrieve created patient"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
created_patient["id"] = str(created_patient["_id"])
|
| 165 |
+
del created_patient["_id"]
|
| 166 |
+
|
| 167 |
+
logger.info(f"Successfully created patient {created_patient['fhir_id']}")
|
| 168 |
+
return created_patient
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Failed to create patient: {str(e)}")
|
| 172 |
+
raise HTTPException(
|
| 173 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 174 |
+
detail=f"Failed to create patient: {str(e)}"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
@router.delete("/patients/{patient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 178 |
+
async def delete_patient(
|
| 179 |
+
patient_id: str,
|
| 180 |
+
current_user: dict = Depends(get_current_user)
|
| 181 |
+
):
|
| 182 |
+
"""Delete a patient from the database"""
|
| 183 |
+
logger.info(f"Deleting patient {patient_id} by user {current_user.get('email')}")
|
| 184 |
+
|
| 185 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 186 |
+
logger.warning(f"Unauthorized delete attempt by {current_user.get('email')}")
|
| 187 |
+
raise HTTPException(
|
| 188 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 189 |
+
detail="Only administrators can delete patients"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
# Build the query based on whether patient_id is a valid ObjectId
|
| 194 |
+
query = {"fhir_id": patient_id}
|
| 195 |
+
if ObjectId.is_valid(patient_id):
|
| 196 |
+
query = {
|
| 197 |
+
"$or": [
|
| 198 |
+
{"_id": ObjectId(patient_id)},
|
| 199 |
+
{"fhir_id": patient_id}
|
| 200 |
+
]
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# Check if patient exists
|
| 204 |
+
patient = await patients_collection.find_one(query)
|
| 205 |
+
|
| 206 |
+
if not patient:
|
| 207 |
+
logger.warning(f"Patient not found for deletion: {patient_id}")
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 210 |
+
detail="Patient not found"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Perform deletion
|
| 214 |
+
result = await patients_collection.delete_one(query)
|
| 215 |
+
|
| 216 |
+
if result.deleted_count == 0:
|
| 217 |
+
logger.error(f"Failed to delete patient {patient_id}")
|
| 218 |
+
raise HTTPException(
|
| 219 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 220 |
+
detail="Failed to delete patient"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
logger.info(f"Successfully deleted patient {patient_id}")
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
except HTTPException:
|
| 227 |
+
raise
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"Failed to delete patient {patient_id}: {str(e)}")
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 232 |
+
detail=f"Failed to delete patient: {str(e)}"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
|
| 236 |
+
logger.debug(f"Processing patient from file: {file_path}")
|
| 237 |
+
patient_data = {}
|
| 238 |
+
notes = []
|
| 239 |
+
conditions = []
|
| 240 |
+
medications = []
|
| 241 |
+
encounters = []
|
| 242 |
+
|
| 243 |
+
# Validate bundle structure
|
| 244 |
+
if not isinstance(bundle, dict) or 'entry' not in bundle:
|
| 245 |
+
logger.error(f"Invalid FHIR bundle structure in {file_path}")
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
for entry in bundle.get('entry', []):
|
| 249 |
+
resource = entry.get('resource', {})
|
| 250 |
+
resource_type = resource.get('resourceType')
|
| 251 |
+
|
| 252 |
+
if not resource_type:
|
| 253 |
+
logger.warning(f"Skipping entry with missing resourceType in {file_path}")
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
if resource_type == 'Patient':
|
| 258 |
+
name = resource.get('name', [{}])[0]
|
| 259 |
+
address = resource.get('address', [{}])[0]
|
| 260 |
+
|
| 261 |
+
# Construct full name and remove numbers
|
| 262 |
+
raw_full_name = f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip()
|
| 263 |
+
clean_full_name = re.sub(r'\d+', '', raw_full_name).strip()
|
| 264 |
+
|
| 265 |
+
patient_data = {
|
| 266 |
+
'fhir_id': resource.get('id'),
|
| 267 |
+
'full_name': clean_full_name,
|
| 268 |
+
'gender': resource.get('gender', 'unknown'),
|
| 269 |
+
'date_of_birth': resource.get('birthDate', ''),
|
| 270 |
+
'address': ' '.join(address.get('line', [''])),
|
| 271 |
+
'city': address.get('city', ''),
|
| 272 |
+
'state': address.get('state', ''),
|
| 273 |
+
'postal_code': address.get('postalCode', ''),
|
| 274 |
+
'country': address.get('country', ''),
|
| 275 |
+
'marital_status': resource.get('maritalStatus', {}).get('text', ''),
|
| 276 |
+
'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
|
| 277 |
+
'source': 'synthea',
|
| 278 |
+
'last_updated': datetime.utcnow().isoformat()
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
elif resource_type == 'Encounter':
|
| 282 |
+
encounter = {
|
| 283 |
+
'id': resource.get('id'),
|
| 284 |
+
'type': resource.get('type', [{}])[0].get('text', ''),
|
| 285 |
+
'status': resource.get('status'),
|
| 286 |
+
'period': resource.get('period', {}),
|
| 287 |
+
'service_provider': resource.get('serviceProvider', {}).get('display', '')
|
| 288 |
+
}
|
| 289 |
+
encounters.append(encounter)
|
| 290 |
+
|
| 291 |
+
for note in resource.get('note', []):
|
| 292 |
+
if note.get('text'):
|
| 293 |
+
notes.append({
|
| 294 |
+
'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
|
| 295 |
+
'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
|
| 296 |
+
'text': note.get('text'),
|
| 297 |
+
'context': f"Encounter: {encounter.get('type')}",
|
| 298 |
+
'author': 'System Generated'
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
elif resource_type == 'Condition':
|
| 302 |
+
conditions.append({
|
| 303 |
+
'id': resource.get('id'),
|
| 304 |
+
'code': resource.get('code', {}).get('text', ''),
|
| 305 |
+
'status': resource.get('clinicalStatus', {}).get('text', ''),
|
| 306 |
+
'onset_date': resource.get('onsetDateTime'),
|
| 307 |
+
'recorded_date': resource.get('recordedDate'),
|
| 308 |
+
'verification_status': resource.get('verificationStatus', {}).get('text', '')
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
elif resource_type == 'MedicationRequest':
|
| 312 |
+
medications.append({
|
| 313 |
+
'id': resource.get('id'),
|
| 314 |
+
'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
|
| 315 |
+
'status': resource.get('status'),
|
| 316 |
+
'prescribed_date': resource.get('authoredOn'),
|
| 317 |
+
'requester': resource.get('requester', {}).get('display', ''),
|
| 318 |
+
'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
except Exception as e:
|
| 322 |
+
logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
|
| 323 |
+
continue
|
| 324 |
+
|
| 325 |
+
if patient_data:
|
| 326 |
+
patient_data.update({
|
| 327 |
+
'notes': notes,
|
| 328 |
+
'conditions': conditions,
|
| 329 |
+
'medications': medications,
|
| 330 |
+
'encounters': encounters,
|
| 331 |
+
'import_date': datetime.utcnow().isoformat()
|
| 332 |
+
})
|
| 333 |
+
logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
|
| 334 |
+
return patient_data
|
| 335 |
+
logger.warning(f"No valid patient data found in {file_path}")
|
| 336 |
+
return None
|
| 337 |
+
|
| 338 |
+
@router.post("/import", status_code=status.HTTP_201_CREATED)
|
| 339 |
+
async def import_patients(
|
| 340 |
+
limit: int = Query(100, ge=1, le=1000),
|
| 341 |
+
current_user: dict = Depends(get_current_user)
|
| 342 |
+
):
|
| 343 |
+
request_id = str(uuid.uuid4())
|
| 344 |
+
logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
|
| 345 |
+
start_time = time.time()
|
| 346 |
+
|
| 347 |
+
if current_user.get('role') not in ['admin', 'doctor']:
|
| 348 |
+
logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
|
| 349 |
+
raise HTTPException(
|
| 350 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 351 |
+
detail="Only administrators and doctors can import data"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
try:
|
| 355 |
+
await create_indexes()
|
| 356 |
+
|
| 357 |
+
if not SYNTHEA_DATA_DIR.exists():
|
| 358 |
+
logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
|
| 359 |
+
raise HTTPException(
|
| 360 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 361 |
+
detail="Data directory not found"
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Filter out non-patient files
|
| 365 |
+
files = [
|
| 366 |
+
f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
|
| 367 |
+
if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
|
| 368 |
+
]
|
| 369 |
+
if not files:
|
| 370 |
+
logger.warning("No valid patient JSON files found in synthea data directory")
|
| 371 |
+
return {
|
| 372 |
+
"status": "success",
|
| 373 |
+
"message": "No patient data files found",
|
| 374 |
+
"imported": 0,
|
| 375 |
+
"request_id": request_id
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
operations = []
|
| 379 |
+
imported = 0
|
| 380 |
+
errors = []
|
| 381 |
+
|
| 382 |
+
for file_path in files[:limit]:
|
| 383 |
+
try:
|
| 384 |
+
logger.debug(f"Processing file: {file_path}")
|
| 385 |
+
|
| 386 |
+
# Check file accessibility
|
| 387 |
+
if not os.path.exists(file_path):
|
| 388 |
+
logger.error(f"File not found: {file_path}")
|
| 389 |
+
errors.append(f"File not found: {file_path}")
|
| 390 |
+
continue
|
| 391 |
+
|
| 392 |
+
# Check file size
|
| 393 |
+
file_size = os.path.getsize(file_path)
|
| 394 |
+
if file_size == 0:
|
| 395 |
+
logger.warning(f"Empty file: {file_path}")
|
| 396 |
+
errors.append(f"Empty file: {file_path}")
|
| 397 |
+
continue
|
| 398 |
+
|
| 399 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 400 |
+
try:
|
| 401 |
+
bundle = json.load(f)
|
| 402 |
+
except json.JSONDecodeError as je:
|
| 403 |
+
logger.error(f"Invalid JSON in {file_path}: {str(je)}")
|
| 404 |
+
errors.append(f"Invalid JSON in {file_path}: {str(je)}")
|
| 405 |
+
continue
|
| 406 |
+
|
| 407 |
+
patient = await process_synthea_patient(bundle, file_path)
|
| 408 |
+
if patient:
|
| 409 |
+
if not patient.get('fhir_id'):
|
| 410 |
+
logger.warning(f"Missing FHIR ID in patient data from {file_path}")
|
| 411 |
+
errors.append(f"Missing FHIR ID in {file_path}")
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
operations.append(UpdateOne(
|
| 415 |
+
{"fhir_id": patient['fhir_id']},
|
| 416 |
+
{"$setOnInsert": patient},
|
| 417 |
+
upsert=True
|
| 418 |
+
))
|
| 419 |
+
imported += 1
|
| 420 |
+
else:
|
| 421 |
+
logger.warning(f"No valid patient data in {file_path}")
|
| 422 |
+
errors.append(f"No valid patient data in {file_path}")
|
| 423 |
+
|
| 424 |
+
except Exception as e:
|
| 425 |
+
logger.error(f"Error processing {file_path}: {str(e)}")
|
| 426 |
+
errors.append(f"Error in {file_path}: {str(e)}")
|
| 427 |
+
continue
|
| 428 |
+
|
| 429 |
+
response = {
|
| 430 |
+
"status": "success",
|
| 431 |
+
"imported": imported,
|
| 432 |
+
"errors": errors,
|
| 433 |
+
"request_id": request_id,
|
| 434 |
+
"duration_seconds": time.time() - start_time
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
if operations:
|
| 438 |
+
try:
|
| 439 |
+
result = await patients_collection.bulk_write(operations, ordered=False)
|
| 440 |
+
response.update({
|
| 441 |
+
"upserted": result.upserted_count,
|
| 442 |
+
"existing": len(operations) - result.upserted_count
|
| 443 |
+
})
|
| 444 |
+
logger.info(f"Import request {request_id} completed: {imported} patients processed, "
|
| 445 |
+
f"{result.upserted_count} upserted, {len(errors)} errors")
|
| 446 |
+
except BulkWriteError as bwe:
|
| 447 |
+
logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
|
| 448 |
+
response.update({
|
| 449 |
+
"upserted": bwe.details.get('nUpserted', 0),
|
| 450 |
+
"existing": len(operations) - bwe.details.get('nUpserted', 0),
|
| 451 |
+
"write_errors": [
|
| 452 |
+
f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
|
| 453 |
+
]
|
| 454 |
+
})
|
| 455 |
+
logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
|
| 456 |
+
f"{response['upserted']} upserted, {len(errors)} errors")
|
| 457 |
+
except Exception as e:
|
| 458 |
+
logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
|
| 459 |
+
raise HTTPException(
|
| 460 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 461 |
+
detail=f"Database operation failed: {str(e)}"
|
| 462 |
+
)
|
| 463 |
+
else:
|
| 464 |
+
logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
|
| 465 |
+
response["message"] = "No new patients found to import"
|
| 466 |
+
|
| 467 |
+
return response
|
| 468 |
+
|
| 469 |
+
except HTTPException:
|
| 470 |
+
raise
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
|
| 473 |
+
raise HTTPException(
|
| 474 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 475 |
+
detail=f"Import failed: {str(e)}"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
@router.post("/patients/import-ehr", status_code=status.HTTP_201_CREATED)
|
| 479 |
+
async def import_ehr_patients(
|
| 480 |
+
ehr_data: List[dict],
|
| 481 |
+
ehr_system: str = Query(..., description="Name of the EHR system"),
|
| 482 |
+
current_user: dict = Depends(get_current_user),
|
| 483 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 484 |
+
):
|
| 485 |
+
"""Import patients from external EHR system"""
|
| 486 |
+
logger.info(f"Importing {len(ehr_data)} patients from EHR system: {ehr_system}")
|
| 487 |
+
|
| 488 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 489 |
+
logger.warning(f"Unauthorized EHR import attempt by {current_user.get('email')}")
|
| 490 |
+
raise HTTPException(
|
| 491 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 492 |
+
detail="Only administrators and doctors can import EHR patients"
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
try:
|
| 496 |
+
imported_patients = []
|
| 497 |
+
skipped_patients = []
|
| 498 |
+
|
| 499 |
+
for patient_data in ehr_data:
|
| 500 |
+
# Check if patient already exists by multiple criteria
|
| 501 |
+
existing_patient = await patients_collection.find_one({
|
| 502 |
+
"$or": [
|
| 503 |
+
{"ehr_id": patient_data.get("ehr_id"), "ehr_system": ehr_system},
|
| 504 |
+
{"full_name": patient_data.get("full_name"), "date_of_birth": patient_data.get("date_of_birth")},
|
| 505 |
+
{"national_id": patient_data.get("national_id")} if patient_data.get("national_id") else {}
|
| 506 |
+
]
|
| 507 |
+
})
|
| 508 |
+
|
| 509 |
+
if existing_patient:
|
| 510 |
+
skipped_patients.append(patient_data.get("full_name", "Unknown"))
|
| 511 |
+
logger.info(f"Patient {patient_data.get('full_name', 'Unknown')} already exists, skipping...")
|
| 512 |
+
continue
|
| 513 |
+
|
| 514 |
+
# Prepare patient document for EHR import
|
| 515 |
+
patient_doc = {
|
| 516 |
+
"full_name": patient_data.get("full_name"),
|
| 517 |
+
"date_of_birth": patient_data.get("date_of_birth"),
|
| 518 |
+
"gender": patient_data.get("gender"),
|
| 519 |
+
"address": patient_data.get("address"),
|
| 520 |
+
"national_id": patient_data.get("national_id"),
|
| 521 |
+
"blood_type": patient_data.get("blood_type"),
|
| 522 |
+
"allergies": patient_data.get("allergies", []),
|
| 523 |
+
"chronic_conditions": patient_data.get("chronic_conditions", []),
|
| 524 |
+
"medications": patient_data.get("medications", []),
|
| 525 |
+
"emergency_contact_name": patient_data.get("emergency_contact_name"),
|
| 526 |
+
"emergency_contact_phone": patient_data.get("emergency_contact_phone"),
|
| 527 |
+
"insurance_provider": patient_data.get("insurance_provider"),
|
| 528 |
+
"insurance_policy_number": patient_data.get("insurance_policy_number"),
|
| 529 |
+
"contact": patient_data.get("contact"),
|
| 530 |
+
"source": "ehr",
|
| 531 |
+
"ehr_id": patient_data.get("ehr_id"),
|
| 532 |
+
"ehr_system": ehr_system,
|
| 533 |
+
"status": "active",
|
| 534 |
+
"registration_date": datetime.now(),
|
| 535 |
+
"created_by": current_user.get('email'),
|
| 536 |
+
"created_at": datetime.now(),
|
| 537 |
+
"updated_at": datetime.now()
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
# Insert patient
|
| 541 |
+
result = await patients_collection.insert_one(patient_doc)
|
| 542 |
+
imported_patients.append(patient_data.get("full_name", "Unknown"))
|
| 543 |
+
|
| 544 |
+
logger.info(f"Successfully imported {len(imported_patients)} patients, skipped {len(skipped_patients)}")
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
"message": f"Successfully imported {len(imported_patients)} patients from {ehr_system}",
|
| 548 |
+
"imported_count": len(imported_patients),
|
| 549 |
+
"skipped_count": len(skipped_patients),
|
| 550 |
+
"imported_patients": imported_patients,
|
| 551 |
+
"skipped_patients": skipped_patients
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.error(f"Error importing EHR patients: {str(e)}")
|
| 556 |
+
raise HTTPException(
|
| 557 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 558 |
+
detail=f"Error importing EHR patients: {str(e)}"
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
@router.get("/patients/sources", response_model=List[dict])
|
| 562 |
+
async def get_patient_sources(
|
| 563 |
+
current_user: dict = Depends(get_current_user)
|
| 564 |
+
):
|
| 565 |
+
"""Get available patient sources and their counts"""
|
| 566 |
+
try:
|
| 567 |
+
# Get counts for each source
|
| 568 |
+
source_counts = await patients_collection.aggregate([
|
| 569 |
+
{
|
| 570 |
+
"$group": {
|
| 571 |
+
"_id": "$source",
|
| 572 |
+
"count": {"$sum": 1}
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
]).to_list(length=None)
|
| 576 |
+
|
| 577 |
+
# Format the response
|
| 578 |
+
sources = []
|
| 579 |
+
for source_count in source_counts:
|
| 580 |
+
source_name = source_count["_id"] or "unknown"
|
| 581 |
+
sources.append({
|
| 582 |
+
"source": source_name,
|
| 583 |
+
"count": source_count["count"],
|
| 584 |
+
"label": source_name.replace("_", " ").title()
|
| 585 |
+
})
|
| 586 |
+
|
| 587 |
+
return sources
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
logger.error(f"Error getting patient sources: {str(e)}")
|
| 591 |
+
raise HTTPException(
|
| 592 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 593 |
+
detail=f"Error getting patient sources: {str(e)}"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
@router.get("/patients", response_model=PatientListResponse)
|
| 597 |
+
async def get_patients(
|
| 598 |
+
page: int = Query(1, ge=1),
|
| 599 |
+
limit: int = Query(20, ge=1, le=100),
|
| 600 |
+
search: Optional[str] = Query(None),
|
| 601 |
+
source: Optional[str] = Query(None), # Filter by patient source
|
| 602 |
+
patient_status: Optional[str] = Query(None), # Filter by patient status
|
| 603 |
+
doctor_id: Optional[str] = Query(None), # Filter by assigned doctor
|
| 604 |
+
current_user: dict = Depends(get_current_user),
|
| 605 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 606 |
+
):
|
| 607 |
+
"""Get patients with filtering options"""
|
| 608 |
+
skip = (page - 1) * limit
|
| 609 |
+
user_id = current_user["_id"]
|
| 610 |
+
|
| 611 |
+
# Debug logging
|
| 612 |
+
logger.info(f"🔍 Getting patients for user: {current_user.get('email')} with roles: {current_user.get('roles', [])}")
|
| 613 |
+
|
| 614 |
+
# Build filter query
|
| 615 |
+
filter_query = {}
|
| 616 |
+
|
| 617 |
+
# Role-based access - apply this first
|
| 618 |
+
if 'admin' not in current_user.get('roles', []):
|
| 619 |
+
if 'doctor' in current_user.get('roles', []):
|
| 620 |
+
# Doctors can see all patients for now (temporarily simplified)
|
| 621 |
+
logger.info("👨⚕️ Doctor access - no restrictions applied")
|
| 622 |
+
pass # No restrictions for doctors
|
| 623 |
+
else:
|
| 624 |
+
# Patients can only see their own record
|
| 625 |
+
logger.info(f"👤 Patient access - restricting to own record: {user_id}")
|
| 626 |
+
filter_query["_id"] = ObjectId(user_id)
|
| 627 |
+
|
| 628 |
+
# Build additional filters
|
| 629 |
+
additional_filters = {}
|
| 630 |
+
|
| 631 |
+
# Add search filter
|
| 632 |
+
if search:
|
| 633 |
+
additional_filters["$or"] = [
|
| 634 |
+
{"full_name": {"$regex": search, "$options": "i"}},
|
| 635 |
+
{"national_id": {"$regex": search, "$options": "i"}},
|
| 636 |
+
{"ehr_id": {"$regex": search, "$options": "i"}}
|
| 637 |
+
]
|
| 638 |
+
|
| 639 |
+
# Add source filter
|
| 640 |
+
if source:
|
| 641 |
+
additional_filters["source"] = source
|
| 642 |
+
|
| 643 |
+
# Add status filter
|
| 644 |
+
if patient_status:
|
| 645 |
+
additional_filters["status"] = patient_status
|
| 646 |
+
|
| 647 |
+
# Add doctor assignment filter
|
| 648 |
+
if doctor_id:
|
| 649 |
+
additional_filters["assigned_doctor_id"] = ObjectId(doctor_id)
|
| 650 |
+
|
| 651 |
+
# Combine filters
|
| 652 |
+
if additional_filters:
|
| 653 |
+
if filter_query.get("$or"):
|
| 654 |
+
# If we have role-based $or, we need to combine with additional filters
|
| 655 |
+
# Create a new $and condition
|
| 656 |
+
filter_query = {
|
| 657 |
+
"$and": [
|
| 658 |
+
filter_query,
|
| 659 |
+
additional_filters
|
| 660 |
+
]
|
| 661 |
+
}
|
| 662 |
+
else:
|
| 663 |
+
# No role-based restrictions, just use additional filters
|
| 664 |
+
filter_query.update(additional_filters)
|
| 665 |
+
|
| 666 |
+
logger.info(f"🔍 Final filter query: {filter_query}")
|
| 667 |
+
|
| 668 |
+
try:
|
| 669 |
+
# Get total count
|
| 670 |
+
total = await patients_collection.count_documents(filter_query)
|
| 671 |
+
logger.info(f"📊 Total patients matching filter: {total}")
|
| 672 |
+
|
| 673 |
+
# Get patients with pagination
|
| 674 |
+
patients_cursor = patients_collection.find(filter_query).skip(skip).limit(limit)
|
| 675 |
+
patients = await patients_cursor.to_list(length=limit)
|
| 676 |
+
logger.info(f"📋 Retrieved {len(patients)} patients")
|
| 677 |
+
|
| 678 |
+
# Process patients to include doctor names and format dates
|
| 679 |
+
processed_patients = []
|
| 680 |
+
for patient in patients:
|
| 681 |
+
# Get assigned doctor name if exists
|
| 682 |
+
assigned_doctor_name = None
|
| 683 |
+
if patient.get("assigned_doctor_id"):
|
| 684 |
+
doctor = await db_client.users.find_one({"_id": patient["assigned_doctor_id"]})
|
| 685 |
+
if doctor:
|
| 686 |
+
assigned_doctor_name = doctor.get("full_name")
|
| 687 |
+
|
| 688 |
+
# Convert ObjectId to string
|
| 689 |
+
patient["id"] = str(patient["_id"])
|
| 690 |
+
del patient["_id"]
|
| 691 |
+
|
| 692 |
+
# Add assigned doctor name
|
| 693 |
+
patient["assigned_doctor_name"] = assigned_doctor_name
|
| 694 |
+
|
| 695 |
+
processed_patients.append(patient)
|
| 696 |
+
|
| 697 |
+
logger.info(f"✅ Returning {len(processed_patients)} processed patients")
|
| 698 |
+
|
| 699 |
+
return PatientListResponse(
|
| 700 |
+
patients=processed_patients,
|
| 701 |
+
total=total,
|
| 702 |
+
page=page,
|
| 703 |
+
limit=limit,
|
| 704 |
+
source_filter=source,
|
| 705 |
+
status_filter=patient_status
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
except Exception as e:
|
| 709 |
+
logger.error(f"❌ Error fetching patients: {str(e)}")
|
| 710 |
+
raise HTTPException(
|
| 711 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 712 |
+
detail=f"Error fetching patients: {str(e)}"
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
@router.get("/patients/{patient_id}", response_model=dict)
|
| 716 |
+
async def get_patient(patient_id: str):
|
| 717 |
+
logger.info(f"Retrieving patient: {patient_id}")
|
| 718 |
+
try:
|
| 719 |
+
patient = await patients_collection.find_one({
|
| 720 |
+
"$or": [
|
| 721 |
+
{"_id": ObjectId(patient_id)},
|
| 722 |
+
{"fhir_id": patient_id}
|
| 723 |
+
]
|
| 724 |
+
})
|
| 725 |
+
|
| 726 |
+
if not patient:
|
| 727 |
+
logger.warning(f"Patient not found: {patient_id}")
|
| 728 |
+
raise HTTPException(
|
| 729 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 730 |
+
detail="Patient not found"
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
+
response = {
|
| 734 |
+
"demographics": {
|
| 735 |
+
"id": str(patient["_id"]),
|
| 736 |
+
"fhir_id": patient.get("fhir_id"),
|
| 737 |
+
"full_name": patient.get("full_name"),
|
| 738 |
+
"gender": patient.get("gender"),
|
| 739 |
+
"date_of_birth": patient.get("date_of_birth"),
|
| 740 |
+
"age": calculate_age(patient.get("date_of_birth")),
|
| 741 |
+
"address": {
|
| 742 |
+
"line": patient.get("address"),
|
| 743 |
+
"city": patient.get("city"),
|
| 744 |
+
"state": patient.get("state"),
|
| 745 |
+
"postal_code": patient.get("postal_code"),
|
| 746 |
+
"country": patient.get("country")
|
| 747 |
+
},
|
| 748 |
+
"marital_status": patient.get("marital_status"),
|
| 749 |
+
"language": patient.get("language")
|
| 750 |
+
},
|
| 751 |
+
"clinical_data": {
|
| 752 |
+
"notes": patient.get("notes", []),
|
| 753 |
+
"conditions": patient.get("conditions", []),
|
| 754 |
+
"medications": patient.get("medications", []),
|
| 755 |
+
"encounters": patient.get("encounters", [])
|
| 756 |
+
},
|
| 757 |
+
"metadata": {
|
| 758 |
+
"source": patient.get("source"),
|
| 759 |
+
"import_date": patient.get("import_date"),
|
| 760 |
+
"last_updated": patient.get("last_updated")
|
| 761 |
+
}
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
logger.info(f"Successfully retrieved patient: {patient_id}")
|
| 765 |
+
return response
|
| 766 |
+
|
| 767 |
+
except ValueError as ve:
|
| 768 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 769 |
+
raise HTTPException(
|
| 770 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 771 |
+
detail="Invalid patient ID format"
|
| 772 |
+
)
|
| 773 |
+
except Exception as e:
|
| 774 |
+
logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
|
| 775 |
+
raise HTTPException(
|
| 776 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 777 |
+
detail=f"Failed to retrieve patient: {str(e)}"
|
| 778 |
+
)
|
| 779 |
+
|
| 780 |
+
@router.post("/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
|
| 781 |
+
async def add_note(
|
| 782 |
+
patient_id: str,
|
| 783 |
+
note: Note,
|
| 784 |
+
current_user: dict = Depends(get_current_user)
|
| 785 |
+
):
|
| 786 |
+
logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
|
| 787 |
+
if current_user.get('role') not in ['doctor', 'admin']:
|
| 788 |
+
logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
|
| 789 |
+
raise HTTPException(
|
| 790 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 791 |
+
detail="Only clinicians can add notes"
|
| 792 |
+
)
|
| 793 |
+
|
| 794 |
+
try:
|
| 795 |
+
note_data = note.dict()
|
| 796 |
+
note_data.update({
|
| 797 |
+
"author": current_user.get('full_name', 'System'),
|
| 798 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 799 |
+
})
|
| 800 |
+
|
| 801 |
+
result = await patients_collection.update_one(
|
| 802 |
+
{"$or": [
|
| 803 |
+
{"_id": ObjectId(patient_id)},
|
| 804 |
+
{"fhir_id": patient_id}
|
| 805 |
+
]},
|
| 806 |
+
{
|
| 807 |
+
"$push": {"notes": note_data},
|
| 808 |
+
"$set": {"last_updated": datetime.utcnow().isoformat()}
|
| 809 |
+
}
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
if result.modified_count == 0:
|
| 813 |
+
logger.warning(f"Patient not found for note addition: {patient_id}")
|
| 814 |
+
raise HTTPException(
|
| 815 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 816 |
+
detail="Patient not found"
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
logger.info(f"Note added successfully for patient {patient_id}")
|
| 820 |
+
return {"status": "success", "message": "Note added"}
|
| 821 |
+
|
| 822 |
+
except ValueError as ve:
|
| 823 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 824 |
+
raise HTTPException(
|
| 825 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 826 |
+
detail="Invalid patient ID format"
|
| 827 |
+
)
|
| 828 |
+
except Exception as e:
|
| 829 |
+
logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
|
| 830 |
+
raise HTTPException(
|
| 831 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 832 |
+
detail=f"Failed to add note: {str(e)}"
|
| 833 |
+
)
|
| 834 |
+
|
| 835 |
+
@router.put("/patients/{patient_id}", status_code=status.HTTP_200_OK)
|
| 836 |
+
async def update_patient(
|
| 837 |
+
patient_id: str,
|
| 838 |
+
update_data: PatientUpdate,
|
| 839 |
+
current_user: dict = Depends(get_current_user)
|
| 840 |
+
):
|
| 841 |
+
"""Update a patient's record in the database"""
|
| 842 |
+
logger.info(f"Updating patient {patient_id} by user {current_user.get('email')}")
|
| 843 |
+
|
| 844 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 845 |
+
logger.warning(f"Unauthorized update attempt by {current_user.get('email')}")
|
| 846 |
+
raise HTTPException(
|
| 847 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 848 |
+
detail="Only administrators and doctors can update patients"
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
try:
|
| 852 |
+
# Build the query based on whether patient_id is a valid ObjectId
|
| 853 |
+
query = {"fhir_id": patient_id}
|
| 854 |
+
if ObjectId.is_valid(patient_id):
|
| 855 |
+
query = {
|
| 856 |
+
"$or": [
|
| 857 |
+
{"_id": ObjectId(patient_id)},
|
| 858 |
+
{"fhir_id": patient_id}
|
| 859 |
+
]
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
# Check if patient exists
|
| 863 |
+
patient = await patients_collection.find_one(query)
|
| 864 |
+
if not patient:
|
| 865 |
+
logger.warning(f"Patient not found for update: {patient_id}")
|
| 866 |
+
raise HTTPException(
|
| 867 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 868 |
+
detail="Patient not found"
|
| 869 |
+
)
|
| 870 |
+
|
| 871 |
+
# Prepare update operations
|
| 872 |
+
update_ops = {"$set": {"last_updated": datetime.utcnow().isoformat()}}
|
| 873 |
+
|
| 874 |
+
# Handle demographic updates
|
| 875 |
+
demographics = {
|
| 876 |
+
"full_name": update_data.full_name,
|
| 877 |
+
"gender": update_data.gender,
|
| 878 |
+
"date_of_birth": update_data.date_of_birth,
|
| 879 |
+
"address": update_data.address,
|
| 880 |
+
"city": update_data.city,
|
| 881 |
+
"state": update_data.state,
|
| 882 |
+
"postal_code": update_data.postal_code,
|
| 883 |
+
"country": update_data.country,
|
| 884 |
+
"marital_status": update_data.marital_status,
|
| 885 |
+
"language": update_data.language
|
| 886 |
+
}
|
| 887 |
+
for key, value in demographics.items():
|
| 888 |
+
if value is not None:
|
| 889 |
+
update_ops["$set"][key] = value
|
| 890 |
+
|
| 891 |
+
# Handle array updates (conditions, medications, encounters, notes)
|
| 892 |
+
array_fields = {
|
| 893 |
+
"conditions": update_data.conditions,
|
| 894 |
+
"medications": update_data.medications,
|
| 895 |
+
"encounters": update_data.encounters,
|
| 896 |
+
"notes": update_data.notes
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
for field, items in array_fields.items():
|
| 900 |
+
if items is not None:
|
| 901 |
+
# Fetch existing items
|
| 902 |
+
existing_items = patient.get(field, [])
|
| 903 |
+
updated_items = []
|
| 904 |
+
|
| 905 |
+
for item in items:
|
| 906 |
+
item_dict = item.dict(exclude_unset=True)
|
| 907 |
+
if not item_dict:
|
| 908 |
+
continue
|
| 909 |
+
|
| 910 |
+
# Generate ID for new items
|
| 911 |
+
if not item_dict.get("id"):
|
| 912 |
+
item_dict["id"] = str(uuid.uuid4())
|
| 913 |
+
|
| 914 |
+
# Validate required fields
|
| 915 |
+
if field == "conditions" and not item_dict.get("code"):
|
| 916 |
+
raise HTTPException(
|
| 917 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 918 |
+
detail=f"Condition code is required for {field}"
|
| 919 |
+
)
|
| 920 |
+
if field == "medications" and not item_dict.get("name"):
|
| 921 |
+
raise HTTPException(
|
| 922 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 923 |
+
detail=f"Medication name is required for {field}"
|
| 924 |
+
)
|
| 925 |
+
if field == "encounters" and not item_dict.get("type"):
|
| 926 |
+
raise HTTPException(
|
| 927 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 928 |
+
detail=f"Encounter type is required for {field}"
|
| 929 |
+
)
|
| 930 |
+
if field == "notes" and not item_dict.get("content"):
|
| 931 |
+
raise HTTPException(
|
| 932 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 933 |
+
detail=f"Note content is required for {field}"
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
updated_items.append(item_dict)
|
| 937 |
+
|
| 938 |
+
# Replace the entire array
|
| 939 |
+
update_ops["$set"][field] = updated_items
|
| 940 |
+
|
| 941 |
+
# Perform the update
|
| 942 |
+
result = await patients_collection.update_one(query, update_ops)
|
| 943 |
+
|
| 944 |
+
if result.modified_count == 0 and result.matched_count == 0:
|
| 945 |
+
logger.warning(f"Patient not found for update: {patient_id}")
|
| 946 |
+
raise HTTPException(
|
| 947 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 948 |
+
detail="Patient not found"
|
| 949 |
+
)
|
| 950 |
+
|
| 951 |
+
# Retrieve and return the updated patient
|
| 952 |
+
updated_patient = await patients_collection.find_one(query)
|
| 953 |
+
if not updated_patient:
|
| 954 |
+
logger.error(f"Failed to retrieve updated patient: {patient_id}")
|
| 955 |
+
raise HTTPException(
|
| 956 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 957 |
+
detail="Failed to retrieve updated patient"
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
response = {
|
| 961 |
+
"id": str(updated_patient["_id"]),
|
| 962 |
+
"fhir_id": updated_patient.get("fhir_id"),
|
| 963 |
+
"full_name": updated_patient.get("full_name"),
|
| 964 |
+
"gender": updated_patient.get("gender"),
|
| 965 |
+
"date_of_birth": updated_patient.get("date_of_birth"),
|
| 966 |
+
"address": updated_patient.get("address"),
|
| 967 |
+
"city": updated_patient.get("city"),
|
| 968 |
+
"state": updated_patient.get("state"),
|
| 969 |
+
"postal_code": updated_patient.get("postal_code"),
|
| 970 |
+
"country": updated_patient.get("country"),
|
| 971 |
+
"marital_status": updated_patient.get("marital_status"),
|
| 972 |
+
"language": updated_patient.get("language"),
|
| 973 |
+
"conditions": updated_patient.get("conditions", []),
|
| 974 |
+
"medications": updated_patient.get("medications", []),
|
| 975 |
+
"encounters": updated_patient.get("encounters", []),
|
| 976 |
+
"notes": updated_patient.get("notes", []),
|
| 977 |
+
"source": updated_patient.get("source"),
|
| 978 |
+
"import_date": updated_patient.get("import_date"),
|
| 979 |
+
"last_updated": updated_patient.get("last_updated")
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
logger.info(f"Successfully updated patient {patient_id}")
|
| 983 |
+
return response
|
| 984 |
+
|
| 985 |
+
except ValueError as ve:
|
| 986 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 987 |
+
raise HTTPException(
|
| 988 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 989 |
+
detail="Invalid patient ID format"
|
| 990 |
+
)
|
| 991 |
+
except HTTPException:
|
| 992 |
+
raise
|
| 993 |
+
except Exception as e:
|
| 994 |
+
logger.error(f"Failed to update patient {patient_id}: {str(e)}")
|
| 995 |
+
raise HTTPException(
|
| 996 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 997 |
+
detail=f"Failed to update patient: {str(e)}"
|
| 998 |
+
)
|
| 999 |
+
|
| 1000 |
+
# FHIR Integration Endpoints
|
| 1001 |
+
@router.post("/patients/import-hapi-fhir", status_code=status.HTTP_201_CREATED)
|
| 1002 |
+
async def import_hapi_patients(
|
| 1003 |
+
limit: int = Query(20, ge=1, le=100, description="Number of patients to import"),
|
| 1004 |
+
current_user: dict = Depends(get_current_user)
|
| 1005 |
+
):
|
| 1006 |
+
"""
|
| 1007 |
+
Import patients from HAPI FHIR Test Server
|
| 1008 |
+
"""
|
| 1009 |
+
try:
|
| 1010 |
+
service = HAPIFHIRIntegrationService()
|
| 1011 |
+
result = await service.import_patients_from_hapi(limit=limit)
|
| 1012 |
+
|
| 1013 |
+
# Create detailed message
|
| 1014 |
+
message_parts = []
|
| 1015 |
+
if result["imported_count"] > 0:
|
| 1016 |
+
message_parts.append(f"Successfully imported {result['imported_count']} patients")
|
| 1017 |
+
if result["skipped_count"] > 0:
|
| 1018 |
+
message_parts.append(f"Skipped {result['skipped_count']} duplicate patients")
|
| 1019 |
+
if result["errors"]:
|
| 1020 |
+
message_parts.append(f"Encountered {len(result['errors'])} errors")
|
| 1021 |
+
|
| 1022 |
+
message = ". ".join(message_parts) + " from HAPI FHIR"
|
| 1023 |
+
|
| 1024 |
+
return {
|
| 1025 |
+
"message": message,
|
| 1026 |
+
"imported_count": result["imported_count"],
|
| 1027 |
+
"skipped_count": result["skipped_count"],
|
| 1028 |
+
"total_found": result["total_found"],
|
| 1029 |
+
"imported_patients": result["imported_patients"],
|
| 1030 |
+
"skipped_patients": result["skipped_patients"],
|
| 1031 |
+
"errors": result["errors"],
|
| 1032 |
+
"source": "hapi_fhir"
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
except Exception as e:
|
| 1036 |
+
logger.error(f"Error importing HAPI FHIR patients: {str(e)}")
|
| 1037 |
+
raise HTTPException(
|
| 1038 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1039 |
+
detail=f"Failed to import patients from HAPI FHIR: {str(e)}"
|
| 1040 |
+
)
|
| 1041 |
+
|
| 1042 |
+
@router.post("/patients/sync-patient/{patient_id}")
|
| 1043 |
+
async def sync_patient_data(
|
| 1044 |
+
patient_id: str,
|
| 1045 |
+
current_user: dict = Depends(get_current_user)
|
| 1046 |
+
):
|
| 1047 |
+
"""
|
| 1048 |
+
Sync a specific patient's data from HAPI FHIR
|
| 1049 |
+
"""
|
| 1050 |
+
try:
|
| 1051 |
+
service = HAPIFHIRIntegrationService()
|
| 1052 |
+
success = await service.sync_patient_data(patient_id)
|
| 1053 |
+
|
| 1054 |
+
if success:
|
| 1055 |
+
return {
|
| 1056 |
+
"message": f"Successfully synced patient {patient_id} from HAPI FHIR",
|
| 1057 |
+
"patient_id": patient_id,
|
| 1058 |
+
"success": True
|
| 1059 |
+
}
|
| 1060 |
+
else:
|
| 1061 |
+
raise HTTPException(
|
| 1062 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1063 |
+
detail=f"Patient {patient_id} not found in HAPI FHIR or sync failed"
|
| 1064 |
+
)
|
| 1065 |
+
|
| 1066 |
+
except HTTPException:
|
| 1067 |
+
raise
|
| 1068 |
+
except Exception as e:
|
| 1069 |
+
logger.error(f"Error syncing patient {patient_id}: {str(e)}")
|
| 1070 |
+
raise HTTPException(
|
| 1071 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1072 |
+
detail=f"Failed to sync patient: {str(e)}"
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
@router.get("/patients/hapi-fhir/patients")
|
| 1076 |
+
async def get_hapi_patients(
|
| 1077 |
+
limit: int = Query(50, ge=1, le=200, description="Number of patients to fetch"),
|
| 1078 |
+
current_user: dict = Depends(get_current_user)
|
| 1079 |
+
):
|
| 1080 |
+
"""
|
| 1081 |
+
Get patients from HAPI FHIR without importing them
|
| 1082 |
+
"""
|
| 1083 |
+
try:
|
| 1084 |
+
service = HAPIFHIRIntegrationService()
|
| 1085 |
+
patients = await service.get_hapi_patients(limit=limit)
|
| 1086 |
+
|
| 1087 |
+
return {
|
| 1088 |
+
"patients": patients,
|
| 1089 |
+
"count": len(patients),
|
| 1090 |
+
"source": "hapi_fhir"
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
except Exception as e:
|
| 1094 |
+
logger.error(f"Error fetching HAPI FHIR patients: {str(e)}")
|
| 1095 |
+
raise HTTPException(
|
| 1096 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1097 |
+
detail=f"Failed to fetch patients from HAPI FHIR: {str(e)}"
|
| 1098 |
+
)
|
| 1099 |
+
|
| 1100 |
+
@router.get("/patients/hapi-fhir/patients/{patient_id}")
|
| 1101 |
+
async def get_hapi_patient_details(
|
| 1102 |
+
patient_id: str,
|
| 1103 |
+
current_user: dict = Depends(get_current_user)
|
| 1104 |
+
):
|
| 1105 |
+
"""
|
| 1106 |
+
Get detailed information for a specific HAPI FHIR patient
|
| 1107 |
+
"""
|
| 1108 |
+
try:
|
| 1109 |
+
service = HAPIFHIRIntegrationService()
|
| 1110 |
+
patient_details = await service.get_hapi_patient_details(patient_id)
|
| 1111 |
+
|
| 1112 |
+
if not patient_details:
|
| 1113 |
+
raise HTTPException(
|
| 1114 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1115 |
+
detail=f"Patient {patient_id} not found in HAPI FHIR"
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
return patient_details
|
| 1119 |
+
|
| 1120 |
+
except HTTPException:
|
| 1121 |
+
raise
|
| 1122 |
+
except Exception as e:
|
| 1123 |
+
logger.error(f"Error fetching HAPI FHIR patient details: {str(e)}")
|
| 1124 |
+
raise HTTPException(
|
| 1125 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1126 |
+
detail=f"Failed to fetch patient details from HAPI FHIR: {str(e)}"
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
@router.get("/patients/hapi-fhir/statistics")
|
| 1130 |
+
async def get_hapi_statistics(
|
| 1131 |
+
current_user: dict = Depends(get_current_user)
|
| 1132 |
+
):
|
| 1133 |
+
"""
|
| 1134 |
+
Get statistics about HAPI FHIR imported patients
|
| 1135 |
+
"""
|
| 1136 |
+
try:
|
| 1137 |
+
service = HAPIFHIRIntegrationService()
|
| 1138 |
+
stats = await service.get_patient_statistics()
|
| 1139 |
+
|
| 1140 |
+
return {
|
| 1141 |
+
"statistics": stats,
|
| 1142 |
+
"source": "hapi_fhir"
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
except Exception as e:
|
| 1146 |
+
logger.error(f"Error getting HAPI FHIR statistics: {str(e)}")
|
| 1147 |
+
raise HTTPException(
|
| 1148 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1149 |
+
detail=f"Failed to get HAPI FHIR statistics: {str(e)}"
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
# Export the router as 'patients' for api.__init__.py
|
| 1153 |
+
patients = router
|
deployment/routes/pdf.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 18 |
+
)
|
| 19 |
+
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 with proper LaTeX formatting
|
| 44 |
+
def prepare_table_content(items, columns, default_message):
|
| 45 |
+
if not items:
|
| 46 |
+
return f"\\multicolumn{{{columns}}}{{l}}{{{default_message}}} \\\\"
|
| 47 |
+
|
| 48 |
+
content = []
|
| 49 |
+
for item in items:
|
| 50 |
+
row = []
|
| 51 |
+
for field in item:
|
| 52 |
+
value = item.get(field, "") or ""
|
| 53 |
+
row.append(escape_latex_special_chars(hyphenate_long_strings(value)))
|
| 54 |
+
content.append(" & ".join(row) + " \\\\")
|
| 55 |
+
return "\n".join(content)
|
| 56 |
+
|
| 57 |
+
# Notes table
|
| 58 |
+
notes = patient.get("notes", [])
|
| 59 |
+
notes_content = prepare_table_content(
|
| 60 |
+
[{
|
| 61 |
+
"date": format_timestamp(n.get("date", "")),
|
| 62 |
+
"type": n.get("type", ""),
|
| 63 |
+
"text": n.get("text", "")
|
| 64 |
+
} for n in notes],
|
| 65 |
+
3,
|
| 66 |
+
"No notes available"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Conditions table
|
| 70 |
+
conditions = patient.get("conditions", [])
|
| 71 |
+
conditions_content = prepare_table_content(
|
| 72 |
+
[{
|
| 73 |
+
"id": c.get("id", ""),
|
| 74 |
+
"code": c.get("code", ""),
|
| 75 |
+
"status": c.get("status", ""),
|
| 76 |
+
"onset": format_timestamp(c.get("onset_date", "")),
|
| 77 |
+
"verification": c.get("verification_status", "")
|
| 78 |
+
} for c in conditions],
|
| 79 |
+
5,
|
| 80 |
+
"No conditions available"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Medications table
|
| 84 |
+
medications = patient.get("medications", [])
|
| 85 |
+
medications_content = prepare_table_content(
|
| 86 |
+
[{
|
| 87 |
+
"id": m.get("id", ""),
|
| 88 |
+
"name": m.get("name", ""),
|
| 89 |
+
"status": m.get("status", ""),
|
| 90 |
+
"date": format_timestamp(m.get("prescribed_date", "")),
|
| 91 |
+
"dosage": m.get("dosage", "")
|
| 92 |
+
} for m in medications],
|
| 93 |
+
5,
|
| 94 |
+
"No medications available"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Encounters table
|
| 98 |
+
encounters = patient.get("encounters", [])
|
| 99 |
+
encounters_content = prepare_table_content(
|
| 100 |
+
[{
|
| 101 |
+
"id": e.get("id", ""),
|
| 102 |
+
"type": e.get("type", ""),
|
| 103 |
+
"status": e.get("status", ""),
|
| 104 |
+
"start": format_timestamp(e.get("period", {}).get("start", "")),
|
| 105 |
+
"provider": e.get("service_provider", "")
|
| 106 |
+
} for e in encounters],
|
| 107 |
+
5,
|
| 108 |
+
"No encounters available"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# LaTeX template with improved table formatting
|
| 112 |
+
latex_template = Template(r"""
|
| 113 |
+
\documentclass[a4paper,12pt]{article}
|
| 114 |
+
\usepackage[utf8]{inputenc}
|
| 115 |
+
\usepackage[T1]{fontenc}
|
| 116 |
+
\usepackage{geometry}
|
| 117 |
+
\geometry{margin=1in}
|
| 118 |
+
\usepackage{booktabs,longtable,fancyhdr}
|
| 119 |
+
\usepackage{array}
|
| 120 |
+
\usepackage{microtype}
|
| 121 |
+
\microtypesetup{expansion=false}
|
| 122 |
+
\setlength{\headheight}{14.5pt}
|
| 123 |
+
\pagestyle{fancy}
|
| 124 |
+
\fancyhf{}
|
| 125 |
+
\fancyhead[L]{Patient Report}
|
| 126 |
+
\fancyhead[R]{Generated: \today}
|
| 127 |
+
\fancyfoot[C]{\thepage}
|
| 128 |
+
\begin{document}
|
| 129 |
+
\begin{center}
|
| 130 |
+
\Large\textbf{Patient Medical Report} \\
|
| 131 |
+
\vspace{0.2cm}
|
| 132 |
+
\textit{Generated on $generated_on}
|
| 133 |
+
\end{center}
|
| 134 |
+
\section*{Demographics}
|
| 135 |
+
\begin{itemize}
|
| 136 |
+
\item \textbf{FHIR ID:} $fhir_id
|
| 137 |
+
\item \textbf{Full Name:} $full_name
|
| 138 |
+
\item \textbf{Gender:} $gender
|
| 139 |
+
\item \textbf{Date of Birth:} $dob
|
| 140 |
+
\item \textbf{Age:} $age
|
| 141 |
+
\item \textbf{Address:} $address
|
| 142 |
+
\item \textbf{Marital Status:} $marital_status
|
| 143 |
+
\item \textbf{Language:} $language
|
| 144 |
+
\end{itemize}
|
| 145 |
+
\section*{Clinical Notes}
|
| 146 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{6.5cm}}
|
| 147 |
+
\caption{Clinical Notes} \\
|
| 148 |
+
\toprule
|
| 149 |
+
\textbf{Date} & \textbf{Type} & \textbf{Text} \\
|
| 150 |
+
\midrule
|
| 151 |
+
$notes
|
| 152 |
+
\bottomrule
|
| 153 |
+
\end{longtable}
|
| 154 |
+
\section*{Conditions}
|
| 155 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
|
| 156 |
+
\caption{Conditions} \\
|
| 157 |
+
\toprule
|
| 158 |
+
\textbf{ID} & \textbf{Code} & \textbf{Status} & \textbf{Onset} & \textbf{Verification} \\
|
| 159 |
+
\midrule
|
| 160 |
+
$conditions
|
| 161 |
+
\bottomrule
|
| 162 |
+
\end{longtable}
|
| 163 |
+
\section*{Medications}
|
| 164 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{4cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
|
| 165 |
+
\caption{Medications} \\
|
| 166 |
+
\toprule
|
| 167 |
+
\textbf{ID} & \textbf{Name} & \textbf{Status} & \textbf{Date} & \textbf{Dosage} \\
|
| 168 |
+
\midrule
|
| 169 |
+
$medications
|
| 170 |
+
\bottomrule
|
| 171 |
+
\end{longtable}
|
| 172 |
+
\section*{Encounters}
|
| 173 |
+
\begin{longtable}[l]{>{\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}}
|
| 174 |
+
\caption{Encounters} \\
|
| 175 |
+
\toprule
|
| 176 |
+
\textbf{ID} & \textbf{Type} & \textbf{Status} & \textbf{Start} & \textbf{Provider} \\
|
| 177 |
+
\midrule
|
| 178 |
+
$encounters
|
| 179 |
+
\bottomrule
|
| 180 |
+
\end{longtable}
|
| 181 |
+
\end{document}
|
| 182 |
+
""")
|
| 183 |
+
|
| 184 |
+
# Set the generated_on date to 02:54 PM CET, May 17, 2025
|
| 185 |
+
generated_on = datetime.strptime("2025-05-17 14:54:00+02:00", "%Y-%m-%d %H:%M:%S%z").strftime("%A, %B %d, %Y at %I:%M %p %Z")
|
| 186 |
+
|
| 187 |
+
latex_filled = latex_template.substitute(
|
| 188 |
+
generated_on=generated_on,
|
| 189 |
+
fhir_id=escape_latex_special_chars(hyphenate_long_strings(patient.get("fhir_id", "") or "")),
|
| 190 |
+
full_name=escape_latex_special_chars(patient.get("full_name", "") or ""),
|
| 191 |
+
gender=escape_latex_special_chars(patient.get("gender", "") or ""),
|
| 192 |
+
dob=escape_latex_special_chars(patient.get("date_of_birth", "") or ""),
|
| 193 |
+
age=escape_latex_special_chars(str(calculate_age(patient.get("date_of_birth", "")) or "N/A")),
|
| 194 |
+
address=escape_latex_special_chars(", ".join(filter(None, [
|
| 195 |
+
patient.get("address", ""),
|
| 196 |
+
patient.get("city", ""),
|
| 197 |
+
patient.get("state", ""),
|
| 198 |
+
patient.get("postal_code", ""),
|
| 199 |
+
patient.get("country", "")
|
| 200 |
+
]))),
|
| 201 |
+
marital_status=escape_latex_special_chars(patient.get("marital_status", "") or ""),
|
| 202 |
+
language=escape_latex_special_chars(patient.get("language", "") or ""),
|
| 203 |
+
notes=notes_content,
|
| 204 |
+
conditions=conditions_content,
|
| 205 |
+
medications=medications_content,
|
| 206 |
+
encounters=encounters_content
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# Compile LaTeX in a temporary directory
|
| 210 |
+
with TemporaryDirectory() as tmpdir:
|
| 211 |
+
tex_path = os.path.join(tmpdir, "report.tex")
|
| 212 |
+
pdf_path = os.path.join(tmpdir, "report.pdf")
|
| 213 |
+
|
| 214 |
+
with open(tex_path, "w", encoding="utf-8") as f:
|
| 215 |
+
f.write(latex_filled)
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
# Run latexmk twice to ensure proper table rendering
|
| 219 |
+
for _ in range(2):
|
| 220 |
+
result = subprocess.run(
|
| 221 |
+
["latexmk", "-pdf", "-interaction=nonstopmode", tex_path],
|
| 222 |
+
cwd=tmpdir,
|
| 223 |
+
check=False,
|
| 224 |
+
capture_output=True,
|
| 225 |
+
text=True
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
if result.returncode != 0:
|
| 229 |
+
raise HTTPException(
|
| 230 |
+
status_code=500,
|
| 231 |
+
detail=f"LaTeX compilation failed: stdout={result.stdout}, stderr={result.stderr}"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
except subprocess.CalledProcessError as e:
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
status_code=500,
|
| 237 |
+
detail=f"LaTeX compilation failed: stdout={e.stdout}, stderr={e.stderr}"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
if not os.path.exists(pdf_path):
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=500,
|
| 243 |
+
detail="PDF file was not generated"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
with open(pdf_path, "rb") as f:
|
| 247 |
+
pdf_bytes = f.read()
|
| 248 |
+
|
| 249 |
+
response = Response(
|
| 250 |
+
content=pdf_bytes,
|
| 251 |
+
media_type="application/pdf",
|
| 252 |
+
headers={"Content-Disposition": f"attachment; filename=patient_{patient.get('fhir_id', 'unknown')}_report.pdf"}
|
| 253 |
+
)
|
| 254 |
+
return response
|
| 255 |
+
|
| 256 |
+
except HTTPException as http_error:
|
| 257 |
+
raise http_error
|
| 258 |
+
except Exception as e:
|
| 259 |
+
raise HTTPException(
|
| 260 |
+
status_code=500,
|
| 261 |
+
detail=f"Unexpected error generating PDF: {str(e)}"
|
| 262 |
+
)
|
| 263 |
+
finally:
|
| 264 |
+
# Restore the logger level for other routes
|
| 265 |
+
logger.setLevel(logging.INFO)
|
| 266 |
+
|
| 267 |
+
# Export the router as 'pdf' for api.__init__.py
|
| 268 |
+
pdf = router
|
deployment/routes/txagent.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from typing import Optional, List
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from core.security import get_current_user
|
| 6 |
+
from api.services.txagent_service import txagent_service
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
class ChatRequest(BaseModel):
|
| 14 |
+
message: str
|
| 15 |
+
history: Optional[List[dict]] = None
|
| 16 |
+
patient_id: Optional[str] = None
|
| 17 |
+
|
| 18 |
+
class VoiceOutputRequest(BaseModel):
|
| 19 |
+
text: str
|
| 20 |
+
language: str = "en-US"
|
| 21 |
+
|
| 22 |
+
@router.get("/txagent/status")
|
| 23 |
+
async def get_txagent_status(current_user: dict = Depends(get_current_user)):
|
| 24 |
+
"""Obtient le statut du service TxAgent"""
|
| 25 |
+
try:
|
| 26 |
+
status = await txagent_service.get_status()
|
| 27 |
+
return {
|
| 28 |
+
"status": "success",
|
| 29 |
+
"txagent_status": status,
|
| 30 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 31 |
+
}
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Error getting TxAgent status: {e}")
|
| 34 |
+
raise HTTPException(status_code=500, detail="Failed to get TxAgent status")
|
| 35 |
+
|
| 36 |
+
@router.post("/txagent/chat")
|
| 37 |
+
async def chat_with_txagent(
|
| 38 |
+
request: ChatRequest,
|
| 39 |
+
current_user: dict = Depends(get_current_user)
|
| 40 |
+
):
|
| 41 |
+
"""Chat avec TxAgent"""
|
| 42 |
+
try:
|
| 43 |
+
# Vérifier que l'utilisateur est médecin ou admin
|
| 44 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 45 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use TxAgent")
|
| 46 |
+
|
| 47 |
+
response = await txagent_service.chat(
|
| 48 |
+
message=request.message,
|
| 49 |
+
history=request.history,
|
| 50 |
+
patient_id=request.patient_id
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"status": "success",
|
| 55 |
+
"response": response,
|
| 56 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 57 |
+
}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Error in TxAgent chat: {e}")
|
| 60 |
+
raise HTTPException(status_code=500, detail="Failed to process chat request")
|
| 61 |
+
|
| 62 |
+
@router.post("/txagent/voice/transcribe")
|
| 63 |
+
async def transcribe_audio(
|
| 64 |
+
audio: UploadFile = File(...),
|
| 65 |
+
current_user: dict = Depends(get_current_user)
|
| 66 |
+
):
|
| 67 |
+
"""Transcription vocale avec TxAgent"""
|
| 68 |
+
try:
|
| 69 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 70 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
|
| 71 |
+
|
| 72 |
+
audio_data = await audio.read()
|
| 73 |
+
result = await txagent_service.voice_transcribe(audio_data)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"status": "success",
|
| 77 |
+
"transcription": result,
|
| 78 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 79 |
+
}
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error in voice transcription: {e}")
|
| 82 |
+
raise HTTPException(status_code=500, detail="Failed to transcribe audio")
|
| 83 |
+
|
| 84 |
+
@router.post("/txagent/voice/synthesize")
|
| 85 |
+
async def synthesize_speech(
|
| 86 |
+
request: VoiceOutputRequest,
|
| 87 |
+
current_user: dict = Depends(get_current_user)
|
| 88 |
+
):
|
| 89 |
+
"""Synthèse vocale avec TxAgent"""
|
| 90 |
+
try:
|
| 91 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 92 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
|
| 93 |
+
|
| 94 |
+
audio_data = await txagent_service.voice_synthesize(
|
| 95 |
+
text=request.text,
|
| 96 |
+
language=request.language
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return StreamingResponse(
|
| 100 |
+
iter([audio_data]),
|
| 101 |
+
media_type="audio/mpeg",
|
| 102 |
+
headers={
|
| 103 |
+
"Content-Disposition": "attachment; filename=synthesized_speech.mp3"
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Error in voice synthesis: {e}")
|
| 108 |
+
raise HTTPException(status_code=500, detail="Failed to synthesize speech")
|
| 109 |
+
|
| 110 |
+
@router.post("/txagent/patients/analyze")
|
| 111 |
+
async def analyze_patient_data(
|
| 112 |
+
patient_data: dict,
|
| 113 |
+
current_user: dict = Depends(get_current_user)
|
| 114 |
+
):
|
| 115 |
+
"""Analyse de données patient avec TxAgent"""
|
| 116 |
+
try:
|
| 117 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 118 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use analysis features")
|
| 119 |
+
|
| 120 |
+
analysis = await txagent_service.analyze_patient(patient_data)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"status": "success",
|
| 124 |
+
"analysis": analysis,
|
| 125 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 126 |
+
}
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Error in patient analysis: {e}")
|
| 129 |
+
raise HTTPException(status_code=500, detail="Failed to analyze patient data")
|
| 130 |
+
|
| 131 |
+
@router.get("/txagent/chats")
|
| 132 |
+
async def get_chats(current_user: dict = Depends(get_current_user)):
|
| 133 |
+
"""Obtient l'historique des chats"""
|
| 134 |
+
try:
|
| 135 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 136 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can access chats")
|
| 137 |
+
|
| 138 |
+
# Cette fonction devra être implémentée dans le service TxAgent
|
| 139 |
+
chats = await txagent_service.get_chats()
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"status": "success",
|
| 143 |
+
"chats": chats,
|
| 144 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 145 |
+
}
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Error getting chats: {e}")
|
| 148 |
+
raise HTTPException(status_code=500, detail="Failed to get chats")
|
| 149 |
+
|
| 150 |
+
@router.get("/txagent/patients/analysis-results")
|
| 151 |
+
async def get_analysis_results(
|
| 152 |
+
risk_filter: Optional[str] = None,
|
| 153 |
+
current_user: dict = Depends(get_current_user)
|
| 154 |
+
):
|
| 155 |
+
"""Obtient les résultats d'analyse des patients"""
|
| 156 |
+
try:
|
| 157 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 158 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can access analysis results")
|
| 159 |
+
|
| 160 |
+
# Cette fonction devra être implémentée dans le service TxAgent
|
| 161 |
+
results = await txagent_service.get_analysis_results(risk_filter)
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"status": "success",
|
| 165 |
+
"results": results,
|
| 166 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 167 |
+
}
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error getting analysis results: {e}")
|
| 170 |
+
raise HTTPException(status_code=500, detail="Failed to get analysis results")
|
deployment/services/fhir_integration.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import List, Dict, Optional
|
| 3 |
+
from api.utils.fhir_client import HAPIFHIRClient
|
| 4 |
+
from db.mongo import db
|
| 5 |
+
|
| 6 |
+
class HAPIFHIRIntegrationService:
|
| 7 |
+
"""
|
| 8 |
+
Service to integrate HAPI FHIR data with your existing database
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.fhir_client = HAPIFHIRClient()
|
| 13 |
+
|
| 14 |
+
async def import_patients_from_hapi(self, limit: int = 20) -> dict:
|
| 15 |
+
"""
|
| 16 |
+
Import patients from HAPI FHIR Test Server with detailed feedback
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
print(f"Fetching {limit} patients from HAPI FHIR...")
|
| 20 |
+
patients = self.fhir_client.get_patients(limit=limit)
|
| 21 |
+
|
| 22 |
+
if not patients:
|
| 23 |
+
print("No patients found in HAPI FHIR")
|
| 24 |
+
return {
|
| 25 |
+
"imported_count": 0,
|
| 26 |
+
"skipped_count": 0,
|
| 27 |
+
"total_found": 0,
|
| 28 |
+
"imported_patients": [],
|
| 29 |
+
"skipped_patients": [],
|
| 30 |
+
"errors": []
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
print(f"Found {len(patients)} patients, checking for duplicates...")
|
| 34 |
+
|
| 35 |
+
imported_count = 0
|
| 36 |
+
skipped_count = 0
|
| 37 |
+
imported_patients = []
|
| 38 |
+
skipped_patients = []
|
| 39 |
+
errors = []
|
| 40 |
+
|
| 41 |
+
for patient in patients:
|
| 42 |
+
try:
|
| 43 |
+
# Check if patient already exists by multiple criteria
|
| 44 |
+
existing = await db.patients.find_one({
|
| 45 |
+
"$or": [
|
| 46 |
+
{"fhir_id": patient['fhir_id']},
|
| 47 |
+
{"full_name": patient['full_name'], "date_of_birth": patient['date_of_birth']},
|
| 48 |
+
{"demographics.fhir_id": patient['fhir_id']}
|
| 49 |
+
]
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
if existing:
|
| 53 |
+
skipped_count += 1
|
| 54 |
+
skipped_patients.append(patient['full_name'])
|
| 55 |
+
print(f"Patient {patient['full_name']} already exists (fhir_id: {patient['fhir_id']}), skipping...")
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
# Enhance patient data with additional FHIR data
|
| 59 |
+
enhanced_patient = await self._enhance_patient_data(patient)
|
| 60 |
+
|
| 61 |
+
# Insert into database
|
| 62 |
+
result = await db.patients.insert_one(enhanced_patient)
|
| 63 |
+
|
| 64 |
+
if result.inserted_id:
|
| 65 |
+
imported_count += 1
|
| 66 |
+
imported_patients.append(patient['full_name'])
|
| 67 |
+
print(f"Imported patient: {patient['full_name']} (ID: {result.inserted_id})")
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
error_msg = f"Error importing patient {patient.get('full_name', 'Unknown')}: {e}"
|
| 71 |
+
errors.append(error_msg)
|
| 72 |
+
print(error_msg)
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
print(f"Import completed: {imported_count} imported, {skipped_count} skipped")
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"imported_count": imported_count,
|
| 79 |
+
"skipped_count": skipped_count,
|
| 80 |
+
"total_found": len(patients),
|
| 81 |
+
"imported_patients": imported_patients,
|
| 82 |
+
"skipped_patients": skipped_patients,
|
| 83 |
+
"errors": errors
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error importing patients: {e}")
|
| 88 |
+
return {
|
| 89 |
+
"imported_count": 0,
|
| 90 |
+
"skipped_count": 0,
|
| 91 |
+
"total_found": 0,
|
| 92 |
+
"imported_patients": [],
|
| 93 |
+
"skipped_patients": [],
|
| 94 |
+
"errors": [str(e)]
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async def _enhance_patient_data(self, patient: Dict) -> Dict:
|
| 98 |
+
"""
|
| 99 |
+
Enhance patient data with additional FHIR resources
|
| 100 |
+
"""
|
| 101 |
+
try:
|
| 102 |
+
patient_id = patient['fhir_id']
|
| 103 |
+
|
| 104 |
+
# Fetch additional data from HAPI FHIR
|
| 105 |
+
observations = self.fhir_client.get_patient_observations(patient_id)
|
| 106 |
+
medications = self.fhir_client.get_patient_medications(patient_id)
|
| 107 |
+
conditions = self.fhir_client.get_patient_conditions(patient_id)
|
| 108 |
+
|
| 109 |
+
# Structure the enhanced patient data
|
| 110 |
+
enhanced_patient = {
|
| 111 |
+
# Basic demographics
|
| 112 |
+
**patient,
|
| 113 |
+
|
| 114 |
+
# Clinical data
|
| 115 |
+
'demographics': {
|
| 116 |
+
'id': patient['id'],
|
| 117 |
+
'fhir_id': patient['fhir_id'],
|
| 118 |
+
'full_name': patient['full_name'],
|
| 119 |
+
'gender': patient['gender'],
|
| 120 |
+
'date_of_birth': patient['date_of_birth'],
|
| 121 |
+
'address': patient['address'],
|
| 122 |
+
'phone': patient.get('phone', ''),
|
| 123 |
+
'email': patient.get('email', ''),
|
| 124 |
+
'marital_status': patient.get('marital_status', 'Unknown'),
|
| 125 |
+
'language': patient.get('language', 'English')
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
'clinical_data': {
|
| 129 |
+
'observations': observations,
|
| 130 |
+
'medications': medications,
|
| 131 |
+
'conditions': conditions,
|
| 132 |
+
'notes': [], # Will be populated separately
|
| 133 |
+
'encounters': [] # Will be populated separately
|
| 134 |
+
},
|
| 135 |
+
|
| 136 |
+
'metadata': {
|
| 137 |
+
'source': 'hapi_fhir',
|
| 138 |
+
'import_date': datetime.now().isoformat(),
|
| 139 |
+
'last_updated': datetime.now().isoformat(),
|
| 140 |
+
'fhir_server': 'https://hapi.fhir.org/baseR4'
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return enhanced_patient
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"Error enhancing patient data: {e}")
|
| 148 |
+
return patient
|
| 149 |
+
|
| 150 |
+
async def sync_patient_data(self, patient_id: str) -> bool:
|
| 151 |
+
"""
|
| 152 |
+
Sync a specific patient's data from HAPI FHIR
|
| 153 |
+
"""
|
| 154 |
+
try:
|
| 155 |
+
# Get patient from HAPI FHIR
|
| 156 |
+
patient = self.fhir_client.get_patient_by_id(patient_id)
|
| 157 |
+
|
| 158 |
+
if not patient:
|
| 159 |
+
print(f"Patient {patient_id} not found in HAPI FHIR")
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
# Enhance with additional data
|
| 163 |
+
enhanced_patient = await self._enhance_patient_data(patient)
|
| 164 |
+
|
| 165 |
+
# Update in database
|
| 166 |
+
result = await db.patients.update_one(
|
| 167 |
+
{"fhir_id": patient_id},
|
| 168 |
+
{"$set": enhanced_patient},
|
| 169 |
+
upsert=True
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if result.modified_count > 0 or result.upserted_id:
|
| 173 |
+
print(f"Synced patient: {patient['full_name']}")
|
| 174 |
+
return True
|
| 175 |
+
else:
|
| 176 |
+
print(f"No changes for patient: {patient['full_name']}")
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
print(f"Error syncing patient {patient_id}: {e}")
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
async def get_patient_statistics(self) -> Dict:
|
| 184 |
+
"""
|
| 185 |
+
Get statistics about imported patients
|
| 186 |
+
"""
|
| 187 |
+
try:
|
| 188 |
+
total_patients = await db.patients.count_documents({})
|
| 189 |
+
hapi_patients = await db.patients.count_documents({"source": "hapi_fhir"})
|
| 190 |
+
|
| 191 |
+
# Get sample patient data and convert ObjectId to string
|
| 192 |
+
sample_patient = await db.patients.find_one({"source": "hapi_fhir"})
|
| 193 |
+
if sample_patient:
|
| 194 |
+
# Convert ObjectId to string for JSON serialization
|
| 195 |
+
sample_patient['_id'] = str(sample_patient['_id'])
|
| 196 |
+
|
| 197 |
+
stats = {
|
| 198 |
+
'total_patients': total_patients,
|
| 199 |
+
'hapi_fhir_patients': hapi_patients,
|
| 200 |
+
'sample_patient': sample_patient
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return stats
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"Error getting statistics: {e}")
|
| 207 |
+
return {}
|
| 208 |
+
|
| 209 |
+
async def get_hapi_patients(self, limit: int = 50) -> List[Dict]:
|
| 210 |
+
"""
|
| 211 |
+
Get patients from HAPI FHIR without importing them
|
| 212 |
+
"""
|
| 213 |
+
try:
|
| 214 |
+
patients = self.fhir_client.get_patients(limit=limit)
|
| 215 |
+
return patients
|
| 216 |
+
except Exception as e:
|
| 217 |
+
print(f"Error fetching HAPI patients: {e}")
|
| 218 |
+
return []
|
| 219 |
+
|
| 220 |
+
async def get_hapi_patient_details(self, patient_id: str) -> Optional[Dict]:
|
| 221 |
+
"""
|
| 222 |
+
Get detailed information for a specific HAPI FHIR patient
|
| 223 |
+
"""
|
| 224 |
+
try:
|
| 225 |
+
patient = self.fhir_client.get_patient_by_id(patient_id)
|
| 226 |
+
if not patient:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
# Get additional data
|
| 230 |
+
observations = self.fhir_client.get_patient_observations(patient_id)
|
| 231 |
+
medications = self.fhir_client.get_patient_medications(patient_id)
|
| 232 |
+
conditions = self.fhir_client.get_patient_conditions(patient_id)
|
| 233 |
+
|
| 234 |
+
return {
|
| 235 |
+
'patient': patient,
|
| 236 |
+
'observations': observations,
|
| 237 |
+
'medications': medications,
|
| 238 |
+
'conditions': conditions
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"Error fetching patient details: {e}")
|
| 243 |
+
return None
|
deployment/services/txagent_service.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import aiohttp
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional, Dict, Any, List
|
| 5 |
+
from core.txagent_config import txagent_config
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class TxAgentService:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.config = txagent_config
|
| 12 |
+
self.session = None
|
| 13 |
+
|
| 14 |
+
async def _get_session(self):
|
| 15 |
+
"""Obtient ou crée une session HTTP"""
|
| 16 |
+
if self.session is None:
|
| 17 |
+
self.session = aiohttp.ClientSession()
|
| 18 |
+
return self.session
|
| 19 |
+
|
| 20 |
+
async def _make_request(self, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Dict[str, Any]:
|
| 21 |
+
"""Fait une requête vers le service TxAgent avec fallback"""
|
| 22 |
+
session = await self._get_session()
|
| 23 |
+
url = f"{self.config.get_txagent_url()}{endpoint}"
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
if method.upper() == "GET":
|
| 27 |
+
async with session.get(url) as response:
|
| 28 |
+
return await response.json()
|
| 29 |
+
elif method.upper() == "POST":
|
| 30 |
+
async with session.post(url, json=data) as response:
|
| 31 |
+
return await response.json()
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Error calling TxAgent service: {e}")
|
| 34 |
+
# Fallback vers cloud si local échoue
|
| 35 |
+
if self.config.get_txagent_mode() == "local":
|
| 36 |
+
logger.info("Falling back to cloud TxAgent service")
|
| 37 |
+
self.config.mode = "cloud"
|
| 38 |
+
return await self._make_request(endpoint, method, data)
|
| 39 |
+
else:
|
| 40 |
+
raise
|
| 41 |
+
|
| 42 |
+
async def chat(self, message: str, history: Optional[list] = None, patient_id: Optional[str] = None) -> Dict[str, Any]:
|
| 43 |
+
"""Service de chat avec TxAgent"""
|
| 44 |
+
data = {
|
| 45 |
+
"message": message,
|
| 46 |
+
"history": history or [],
|
| 47 |
+
"patient_id": patient_id
|
| 48 |
+
}
|
| 49 |
+
return await self._make_request("/chat", "POST", data)
|
| 50 |
+
|
| 51 |
+
async def analyze_patient(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 52 |
+
"""Analyse de données patient avec TxAgent"""
|
| 53 |
+
return await self._make_request("/patients/analyze", "POST", patient_data)
|
| 54 |
+
|
| 55 |
+
async def voice_transcribe(self, audio_data: bytes) -> Dict[str, Any]:
|
| 56 |
+
"""Transcription vocale avec TxAgent"""
|
| 57 |
+
session = await self._get_session()
|
| 58 |
+
url = f"{self.config.get_txagent_url()}/voice/transcribe"
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
form_data = aiohttp.FormData()
|
| 62 |
+
form_data.add_field('audio', audio_data, filename='audio.wav')
|
| 63 |
+
|
| 64 |
+
async with session.post(url, data=form_data) as response:
|
| 65 |
+
return await response.json()
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Error in voice transcription: {e}")
|
| 68 |
+
if self.config.get_txagent_mode() == "local":
|
| 69 |
+
self.config.mode = "cloud"
|
| 70 |
+
return await self.voice_transcribe(audio_data)
|
| 71 |
+
else:
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
async def voice_synthesize(self, text: str, language: str = "en-US") -> bytes:
|
| 75 |
+
"""Synthèse vocale avec TxAgent"""
|
| 76 |
+
session = await self._get_session()
|
| 77 |
+
url = f"{self.config.get_txagent_url()}/voice/synthesize"
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
data = {
|
| 81 |
+
"text": text,
|
| 82 |
+
"language": language,
|
| 83 |
+
"return_format": "mp3"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async with session.post(url, json=data) as response:
|
| 87 |
+
return await response.read()
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error in voice synthesis: {e}")
|
| 90 |
+
if self.config.get_txagent_mode() == "local":
|
| 91 |
+
self.config.mode = "cloud"
|
| 92 |
+
return await self.voice_synthesize(text, language)
|
| 93 |
+
else:
|
| 94 |
+
raise
|
| 95 |
+
|
| 96 |
+
async def get_status(self) -> Dict[str, Any]:
|
| 97 |
+
"""Obtient le statut du service TxAgent"""
|
| 98 |
+
return await self._make_request("/status")
|
| 99 |
+
|
| 100 |
+
async def get_chats(self) -> List[Dict[str, Any]]:
|
| 101 |
+
"""Obtient l'historique des chats"""
|
| 102 |
+
return await self._make_request("/chats")
|
| 103 |
+
|
| 104 |
+
async def get_analysis_results(self, risk_filter: Optional[str] = None) -> List[Dict[str, Any]]:
|
| 105 |
+
"""Obtient les résultats d'analyse des patients"""
|
| 106 |
+
params = {}
|
| 107 |
+
if risk_filter:
|
| 108 |
+
params["risk_filter"] = risk_filter
|
| 109 |
+
return await self._make_request("/patients/analysis-results", "GET", params)
|
| 110 |
+
|
| 111 |
+
async def close(self):
|
| 112 |
+
"""Ferme la session HTTP"""
|
| 113 |
+
if self.session:
|
| 114 |
+
await self.session.close()
|
| 115 |
+
self.session = None
|
| 116 |
+
|
| 117 |
+
# Instance globale
|
| 118 |
+
txagent_service = TxAgentService()
|
deployment/utils/fhir_client.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
from typing import List, Dict, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
class HAPIFHIRClient:
|
| 7 |
+
"""
|
| 8 |
+
Client for connecting to HAPI FHIR Test Server
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
def __init__(self, base_url: str = "https://hapi.fhir.org/baseR4"):
|
| 12 |
+
self.base_url = base_url
|
| 13 |
+
self.session = requests.Session()
|
| 14 |
+
self.session.headers.update({
|
| 15 |
+
'Content-Type': 'application/fhir+json',
|
| 16 |
+
'Accept': 'application/fhir+json'
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
def get_patients(self, limit: int = 50) -> List[Dict]:
|
| 20 |
+
"""
|
| 21 |
+
Fetch patients from HAPI FHIR Test Server
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
url = f"{self.base_url}/Patient"
|
| 25 |
+
params = {
|
| 26 |
+
'_count': limit,
|
| 27 |
+
'_format': 'json'
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
response = self.session.get(url, params=params)
|
| 31 |
+
response.raise_for_status()
|
| 32 |
+
|
| 33 |
+
data = response.json()
|
| 34 |
+
patients = []
|
| 35 |
+
|
| 36 |
+
if 'entry' in data:
|
| 37 |
+
for entry in data['entry']:
|
| 38 |
+
patient = self._parse_patient(entry['resource'])
|
| 39 |
+
if patient:
|
| 40 |
+
patients.append(patient)
|
| 41 |
+
|
| 42 |
+
return patients
|
| 43 |
+
|
| 44 |
+
except requests.RequestException as e:
|
| 45 |
+
print(f"Error fetching patients: {e}")
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
def get_patient_by_id(self, patient_id: str) -> Optional[Dict]:
|
| 49 |
+
"""
|
| 50 |
+
Fetch a specific patient by ID
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
url = f"{self.base_url}/Patient/{patient_id}"
|
| 54 |
+
response = self.session.get(url)
|
| 55 |
+
response.raise_for_status()
|
| 56 |
+
|
| 57 |
+
patient_data = response.json()
|
| 58 |
+
return self._parse_patient(patient_data)
|
| 59 |
+
|
| 60 |
+
except requests.RequestException as e:
|
| 61 |
+
print(f"Error fetching patient {patient_id}: {e}")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
def get_patient_observations(self, patient_id: str) -> List[Dict]:
|
| 65 |
+
"""
|
| 66 |
+
Fetch observations (vital signs, lab results) for a patient
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
url = f"{self.base_url}/Observation"
|
| 70 |
+
params = {
|
| 71 |
+
'subject': f"Patient/{patient_id}",
|
| 72 |
+
'_count': 100,
|
| 73 |
+
'_format': 'json'
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
response = self.session.get(url, params=params)
|
| 77 |
+
response.raise_for_status()
|
| 78 |
+
|
| 79 |
+
data = response.json()
|
| 80 |
+
observations = []
|
| 81 |
+
|
| 82 |
+
if 'entry' in data:
|
| 83 |
+
for entry in data['entry']:
|
| 84 |
+
observation = self._parse_observation(entry['resource'])
|
| 85 |
+
if observation:
|
| 86 |
+
observations.append(observation)
|
| 87 |
+
|
| 88 |
+
return observations
|
| 89 |
+
|
| 90 |
+
except requests.RequestException as e:
|
| 91 |
+
print(f"Error fetching observations for patient {patient_id}: {e}")
|
| 92 |
+
return []
|
| 93 |
+
|
| 94 |
+
def get_patient_medications(self, patient_id: str) -> List[Dict]:
|
| 95 |
+
"""
|
| 96 |
+
Fetch medications for a patient
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
url = f"{self.base_url}/MedicationRequest"
|
| 100 |
+
params = {
|
| 101 |
+
'subject': f"Patient/{patient_id}",
|
| 102 |
+
'_count': 100,
|
| 103 |
+
'_format': 'json'
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
response = self.session.get(url, params=params)
|
| 107 |
+
response.raise_for_status()
|
| 108 |
+
|
| 109 |
+
data = response.json()
|
| 110 |
+
medications = []
|
| 111 |
+
|
| 112 |
+
if 'entry' in data:
|
| 113 |
+
for entry in data['entry']:
|
| 114 |
+
medication = self._parse_medication(entry['resource'])
|
| 115 |
+
if medication:
|
| 116 |
+
medications.append(medication)
|
| 117 |
+
|
| 118 |
+
return medications
|
| 119 |
+
|
| 120 |
+
except requests.RequestException as e:
|
| 121 |
+
print(f"Error fetching medications for patient {patient_id}: {e}")
|
| 122 |
+
return []
|
| 123 |
+
|
| 124 |
+
def get_patient_conditions(self, patient_id: str) -> List[Dict]:
|
| 125 |
+
"""
|
| 126 |
+
Fetch conditions (diagnoses) for a patient
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
url = f"{self.base_url}/Condition"
|
| 130 |
+
params = {
|
| 131 |
+
'subject': f"Patient/{patient_id}",
|
| 132 |
+
'_count': 100,
|
| 133 |
+
'_format': 'json'
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
response = self.session.get(url, params=params)
|
| 137 |
+
response.raise_for_status()
|
| 138 |
+
|
| 139 |
+
data = response.json()
|
| 140 |
+
conditions = []
|
| 141 |
+
|
| 142 |
+
if 'entry' in data:
|
| 143 |
+
for entry in data['entry']:
|
| 144 |
+
condition = self._parse_condition(entry['resource'])
|
| 145 |
+
if condition:
|
| 146 |
+
conditions.append(condition)
|
| 147 |
+
|
| 148 |
+
return conditions
|
| 149 |
+
|
| 150 |
+
except requests.RequestException as e:
|
| 151 |
+
print(f"Error fetching conditions for patient {patient_id}: {e}")
|
| 152 |
+
return []
|
| 153 |
+
|
| 154 |
+
def _parse_patient(self, patient_data: Dict) -> Optional[Dict]:
|
| 155 |
+
"""
|
| 156 |
+
Parse FHIR Patient resource into our format
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
# Extract basic demographics
|
| 160 |
+
name = ""
|
| 161 |
+
if 'name' in patient_data and patient_data['name']:
|
| 162 |
+
name_parts = patient_data['name'][0]
|
| 163 |
+
given = name_parts.get('given', [])
|
| 164 |
+
family = name_parts.get('family', '')
|
| 165 |
+
name = f"{' '.join(given)} {family}".strip()
|
| 166 |
+
|
| 167 |
+
# Extract address
|
| 168 |
+
address = ""
|
| 169 |
+
if 'address' in patient_data and patient_data['address']:
|
| 170 |
+
addr = patient_data['address'][0]
|
| 171 |
+
line = addr.get('line', [])
|
| 172 |
+
city = addr.get('city', '')
|
| 173 |
+
state = addr.get('state', '')
|
| 174 |
+
postal_code = addr.get('postalCode', '')
|
| 175 |
+
address = f"{', '.join(line)}, {city}, {state} {postal_code}".strip()
|
| 176 |
+
|
| 177 |
+
# Extract contact info
|
| 178 |
+
phone = ""
|
| 179 |
+
email = ""
|
| 180 |
+
if 'telecom' in patient_data:
|
| 181 |
+
for telecom in patient_data['telecom']:
|
| 182 |
+
if telecom.get('system') == 'phone':
|
| 183 |
+
phone = telecom.get('value', '')
|
| 184 |
+
elif telecom.get('system') == 'email':
|
| 185 |
+
email = telecom.get('value', '')
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
'id': patient_data.get('id', ''),
|
| 189 |
+
'fhir_id': patient_data.get('id', ''),
|
| 190 |
+
'full_name': name,
|
| 191 |
+
'gender': patient_data.get('gender', 'unknown'),
|
| 192 |
+
'date_of_birth': patient_data.get('birthDate', ''),
|
| 193 |
+
'address': address,
|
| 194 |
+
'phone': phone,
|
| 195 |
+
'email': email,
|
| 196 |
+
'marital_status': self._get_marital_status(patient_data),
|
| 197 |
+
'language': self._get_language(patient_data),
|
| 198 |
+
'source': 'hapi_fhir',
|
| 199 |
+
'status': 'active',
|
| 200 |
+
'created_at': datetime.now().isoformat(),
|
| 201 |
+
'updated_at': datetime.now().isoformat()
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"Error parsing patient data: {e}")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def _parse_observation(self, observation_data: Dict) -> Optional[Dict]:
|
| 209 |
+
"""
|
| 210 |
+
Parse FHIR Observation resource
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
code = observation_data.get('code', {})
|
| 214 |
+
coding = code.get('coding', [])
|
| 215 |
+
code_text = code.get('text', '')
|
| 216 |
+
|
| 217 |
+
if coding:
|
| 218 |
+
code_text = coding[0].get('display', code_text)
|
| 219 |
+
|
| 220 |
+
value = observation_data.get('valueQuantity', {})
|
| 221 |
+
unit = value.get('unit', '')
|
| 222 |
+
value_amount = value.get('value', '')
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
'id': observation_data.get('id', ''),
|
| 226 |
+
'code': code_text,
|
| 227 |
+
'value': f"{value_amount} {unit}".strip(),
|
| 228 |
+
'date': observation_data.get('effectiveDateTime', ''),
|
| 229 |
+
'category': self._get_observation_category(observation_data)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f"Error parsing observation: {e}")
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
def _parse_medication(self, medication_data: Dict) -> Optional[Dict]:
|
| 237 |
+
"""
|
| 238 |
+
Parse FHIR MedicationRequest resource
|
| 239 |
+
"""
|
| 240 |
+
try:
|
| 241 |
+
medication = medication_data.get('medicationCodeableConcept', {})
|
| 242 |
+
coding = medication.get('coding', [])
|
| 243 |
+
name = medication.get('text', '')
|
| 244 |
+
|
| 245 |
+
if coding:
|
| 246 |
+
name = coding[0].get('display', name)
|
| 247 |
+
|
| 248 |
+
dosage = medication_data.get('dosageInstruction', [])
|
| 249 |
+
dosage_text = ""
|
| 250 |
+
if dosage:
|
| 251 |
+
dosage_text = dosage[0].get('text', '')
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
'id': medication_data.get('id', ''),
|
| 255 |
+
'name': name,
|
| 256 |
+
'dosage': dosage_text,
|
| 257 |
+
'status': medication_data.get('status', 'active'),
|
| 258 |
+
'prescribed_date': medication_data.get('authoredOn', ''),
|
| 259 |
+
'requester': self._get_practitioner_name(medication_data)
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
print(f"Error parsing medication: {e}")
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
def _parse_condition(self, condition_data: Dict) -> Optional[Dict]:
|
| 267 |
+
"""
|
| 268 |
+
Parse FHIR Condition resource
|
| 269 |
+
"""
|
| 270 |
+
try:
|
| 271 |
+
code = condition_data.get('code', {})
|
| 272 |
+
coding = code.get('coding', [])
|
| 273 |
+
name = code.get('text', '')
|
| 274 |
+
|
| 275 |
+
if coding:
|
| 276 |
+
name = coding[0].get('display', name)
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
'id': condition_data.get('id', ''),
|
| 280 |
+
'code': name,
|
| 281 |
+
'status': condition_data.get('clinicalStatus', {}).get('coding', [{}])[0].get('code', 'active'),
|
| 282 |
+
'onset_date': condition_data.get('onsetDateTime', ''),
|
| 283 |
+
'recorded_date': condition_data.get('recordedDate', ''),
|
| 284 |
+
'notes': condition_data.get('note', [{}])[0].get('text', '') if condition_data.get('note') else ''
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
print(f"Error parsing condition: {e}")
|
| 289 |
+
return None
|
| 290 |
+
|
| 291 |
+
def _get_marital_status(self, patient_data: Dict) -> str:
|
| 292 |
+
"""Extract marital status from patient data"""
|
| 293 |
+
if 'maritalStatus' in patient_data:
|
| 294 |
+
coding = patient_data['maritalStatus'].get('coding', [])
|
| 295 |
+
if coding:
|
| 296 |
+
return coding[0].get('display', 'Unknown')
|
| 297 |
+
return 'Unknown'
|
| 298 |
+
|
| 299 |
+
def _get_language(self, patient_data: Dict) -> str:
|
| 300 |
+
"""Extract language from patient data"""
|
| 301 |
+
if 'communication' in patient_data and patient_data['communication']:
|
| 302 |
+
language = patient_data['communication'][0].get('language', {})
|
| 303 |
+
coding = language.get('coding', [])
|
| 304 |
+
if coding:
|
| 305 |
+
return coding[0].get('display', 'English')
|
| 306 |
+
return 'English'
|
| 307 |
+
|
| 308 |
+
def _get_observation_category(self, observation_data: Dict) -> str:
|
| 309 |
+
"""Extract observation category"""
|
| 310 |
+
category = observation_data.get('category', {})
|
| 311 |
+
coding = category.get('coding', [])
|
| 312 |
+
if coding:
|
| 313 |
+
return coding[0].get('display', 'Unknown')
|
| 314 |
+
return 'Unknown'
|
| 315 |
+
|
| 316 |
+
def _get_practitioner_name(self, medication_data: Dict) -> str:
|
| 317 |
+
"""Extract practitioner name from medication request"""
|
| 318 |
+
requester = medication_data.get('requester', {})
|
| 319 |
+
reference = requester.get('reference', '')
|
| 320 |
+
if reference.startswith('Practitioner/'):
|
| 321 |
+
# In a real implementation, you'd fetch the practitioner details
|
| 322 |
+
return 'Dr. Practitioner'
|
| 323 |
+
return 'Unknown'
|
routes.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
#
|
routes/__init__.py
ADDED
|
File without changes
|
routes/appointments.py
ADDED
|
@@ -0,0 +1,904 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import date, time, datetime, timedelta
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 6 |
+
|
| 7 |
+
from core.security import get_current_user
|
| 8 |
+
from db.mongo import db, patients_collection
|
| 9 |
+
from models.schemas import (
|
| 10 |
+
AppointmentCreate, AppointmentUpdate, AppointmentResponse, AppointmentListResponse,
|
| 11 |
+
AppointmentStatus, AppointmentType, DoctorAvailabilityCreate, DoctorAvailabilityUpdate,
|
| 12 |
+
DoctorAvailabilityResponse, AvailableSlotsResponse, AppointmentSlot, DoctorListResponse
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
router = APIRouter(prefix="/appointments", tags=["appointments"])
|
| 16 |
+
|
| 17 |
+
# --- HELPER FUNCTIONS ---
|
| 18 |
+
def is_valid_object_id(id_str: str) -> bool:
|
| 19 |
+
try:
|
| 20 |
+
ObjectId(id_str)
|
| 21 |
+
return True
|
| 22 |
+
except:
|
| 23 |
+
return False
|
| 24 |
+
|
| 25 |
+
def get_weekday_from_date(appointment_date: date) -> int:
|
| 26 |
+
"""Convert date to weekday (0=Monday, 6=Sunday)"""
|
| 27 |
+
return appointment_date.weekday()
|
| 28 |
+
|
| 29 |
+
def generate_time_slots(start_time: time, end_time: time, duration: int = 30) -> List[time]:
|
| 30 |
+
"""Generate time slots between start and end time"""
|
| 31 |
+
slots = []
|
| 32 |
+
current_time = datetime.combine(date.today(), start_time)
|
| 33 |
+
end_datetime = datetime.combine(date.today(), end_time)
|
| 34 |
+
|
| 35 |
+
while current_time < end_datetime:
|
| 36 |
+
slots.append(current_time.time())
|
| 37 |
+
current_time += timedelta(minutes=duration)
|
| 38 |
+
|
| 39 |
+
return slots
|
| 40 |
+
|
| 41 |
+
# --- PATIENT ASSIGNMENT ENDPOINT ---
|
| 42 |
+
|
| 43 |
+
@router.post("/assign-patient", status_code=status.HTTP_200_OK)
|
| 44 |
+
async def assign_patient_to_doctor(
|
| 45 |
+
patient_id: str,
|
| 46 |
+
doctor_id: str,
|
| 47 |
+
current_user: dict = Depends(get_current_user),
|
| 48 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 49 |
+
):
|
| 50 |
+
"""Manually assign a patient to a doctor"""
|
| 51 |
+
# Only doctors and admins can assign patients
|
| 52 |
+
if not (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or
|
| 53 |
+
('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
|
| 54 |
+
raise HTTPException(
|
| 55 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 56 |
+
detail="Only doctors and admins can assign patients"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
# Validate ObjectIds
|
| 60 |
+
if not is_valid_object_id(patient_id) or not is_valid_object_id(doctor_id):
|
| 61 |
+
raise HTTPException(
|
| 62 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 63 |
+
detail="Invalid patient_id or doctor_id"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
# Check if doctor exists and is a doctor
|
| 67 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 68 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 71 |
+
detail="Doctor not found"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
# Check if patient exists
|
| 75 |
+
patient = await db_client.users.find_one({"_id": ObjectId(patient_id)})
|
| 76 |
+
if not patient:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 79 |
+
detail="Patient not found"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
# Check if patient already exists in patients collection
|
| 84 |
+
existing_patient = await patients_collection.find_one({
|
| 85 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 86 |
+
})
|
| 87 |
+
|
| 88 |
+
if existing_patient:
|
| 89 |
+
# Update existing patient to assign to this doctor
|
| 90 |
+
await patients_collection.update_one(
|
| 91 |
+
{"_id": existing_patient["_id"]},
|
| 92 |
+
{
|
| 93 |
+
"$set": {
|
| 94 |
+
"assigned_doctor_id": str(doctor_id), # Convert to string
|
| 95 |
+
"updated_at": datetime.now()
|
| 96 |
+
}
|
| 97 |
+
}
|
| 98 |
+
)
|
| 99 |
+
message = f"Patient {patient.get('full_name', 'Unknown')} reassigned to doctor {doctor.get('full_name', 'Unknown')}"
|
| 100 |
+
else:
|
| 101 |
+
# Create new patient record in patients collection
|
| 102 |
+
patient_doc = {
|
| 103 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 104 |
+
"full_name": patient.get("full_name", ""),
|
| 105 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 106 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 107 |
+
"address": patient.get("address"),
|
| 108 |
+
"city": patient.get("city"),
|
| 109 |
+
"state": patient.get("state"),
|
| 110 |
+
"postal_code": patient.get("postal_code"),
|
| 111 |
+
"country": patient.get("country"),
|
| 112 |
+
"national_id": patient.get("national_id"),
|
| 113 |
+
"blood_type": patient.get("blood_type"),
|
| 114 |
+
"allergies": patient.get("allergies", []),
|
| 115 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 116 |
+
"medications": patient.get("medications", []),
|
| 117 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 118 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 119 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 120 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 121 |
+
"notes": patient.get("notes", []),
|
| 122 |
+
"source": "manual", # Manually assigned
|
| 123 |
+
"status": "active",
|
| 124 |
+
"assigned_doctor_id": str(doctor_id), # Convert to string
|
| 125 |
+
"registration_date": datetime.now(),
|
| 126 |
+
"created_at": datetime.now(),
|
| 127 |
+
"updated_at": datetime.now()
|
| 128 |
+
}
|
| 129 |
+
await patients_collection.insert_one(patient_doc)
|
| 130 |
+
message = f"Patient {patient.get('full_name', 'Unknown')} assigned to doctor {doctor.get('full_name', 'Unknown')}"
|
| 131 |
+
|
| 132 |
+
print(f"✅ {message}")
|
| 133 |
+
return {"message": message, "success": True}
|
| 134 |
+
|
| 135 |
+
except Exception as e:
|
| 136 |
+
print(f"❌ Error assigning patient to doctor: {str(e)}")
|
| 137 |
+
raise HTTPException(
|
| 138 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 139 |
+
detail=f"Failed to assign patient to doctor: {str(e)}"
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# --- DOCTOR LIST ENDPOINT (MUST BE BEFORE PARAMETERIZED ROUTES) ---
|
| 143 |
+
|
| 144 |
+
@router.get("/doctors", response_model=List[DoctorListResponse])
|
| 145 |
+
async def get_doctors(
|
| 146 |
+
specialty: Optional[str] = Query(None),
|
| 147 |
+
current_user: dict = Depends(get_current_user),
|
| 148 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 149 |
+
):
|
| 150 |
+
"""Get list of available doctors"""
|
| 151 |
+
# Build filter - handle both "role" (singular) and "roles" (array) fields
|
| 152 |
+
filter_query = {
|
| 153 |
+
"$or": [
|
| 154 |
+
{"roles": {"$in": ["doctor"]}},
|
| 155 |
+
{"role": "doctor"}
|
| 156 |
+
]
|
| 157 |
+
}
|
| 158 |
+
if specialty:
|
| 159 |
+
filter_query["$and"] = [
|
| 160 |
+
{"$or": [{"roles": {"$in": ["doctor"]}}, {"role": "doctor"}]},
|
| 161 |
+
{"specialty": {"$regex": specialty, "$options": "i"}}
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
# Get doctors
|
| 165 |
+
cursor = db_client.users.find(filter_query)
|
| 166 |
+
doctors = await cursor.to_list(length=None)
|
| 167 |
+
|
| 168 |
+
return [
|
| 169 |
+
DoctorListResponse(
|
| 170 |
+
id=str(doctor["_id"]),
|
| 171 |
+
full_name=doctor['full_name'],
|
| 172 |
+
specialty=doctor.get('specialty', ''),
|
| 173 |
+
license_number=doctor.get('license_number', ''),
|
| 174 |
+
email=doctor['email'],
|
| 175 |
+
phone=doctor.get('phone')
|
| 176 |
+
)
|
| 177 |
+
for doctor in doctors
|
| 178 |
+
]
|
| 179 |
+
|
| 180 |
+
# --- DOCTOR AVAILABILITY ENDPOINTS ---
|
| 181 |
+
|
| 182 |
+
@router.post("/availability", response_model=DoctorAvailabilityResponse, status_code=status.HTTP_201_CREATED)
|
| 183 |
+
async def create_doctor_availability(
|
| 184 |
+
availability_data: DoctorAvailabilityCreate,
|
| 185 |
+
current_user: dict = Depends(get_current_user),
|
| 186 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 187 |
+
):
|
| 188 |
+
"""Create doctor availability"""
|
| 189 |
+
if (current_user.get('role') != 'doctor' and 'doctor' not in current_user.get('roles', [])) and 'admin' not in current_user.get('roles', []):
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 192 |
+
detail="Only doctors can set their availability"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Check if doctor exists
|
| 196 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(availability_data.doctor_id)})
|
| 197 |
+
if not doctor:
|
| 198 |
+
raise HTTPException(
|
| 199 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 200 |
+
detail="Doctor not found"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Check if doctor is setting their own availability or admin is setting it
|
| 204 |
+
if (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])) and availability_data.doctor_id != current_user["_id"]:
|
| 205 |
+
raise HTTPException(
|
| 206 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 207 |
+
detail="You can only set your own availability"
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
# Check if availability already exists for this day
|
| 211 |
+
existing = await db_client.doctor_availability.find_one({
|
| 212 |
+
"doctor_id": ObjectId(availability_data.doctor_id),
|
| 213 |
+
"day_of_week": availability_data.day_of_week
|
| 214 |
+
})
|
| 215 |
+
|
| 216 |
+
if existing:
|
| 217 |
+
raise HTTPException(
|
| 218 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 219 |
+
detail="Availability already exists for this day"
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# Create availability
|
| 223 |
+
availability_doc = {
|
| 224 |
+
"doctor_id": ObjectId(availability_data.doctor_id),
|
| 225 |
+
"day_of_week": availability_data.day_of_week,
|
| 226 |
+
"start_time": availability_data.start_time,
|
| 227 |
+
"end_time": availability_data.end_time,
|
| 228 |
+
"is_available": availability_data.is_available
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
result = await db_client.doctor_availability.insert_one(availability_doc)
|
| 232 |
+
availability_doc["_id"] = result.inserted_id
|
| 233 |
+
|
| 234 |
+
return DoctorAvailabilityResponse(
|
| 235 |
+
id=str(availability_doc["_id"]),
|
| 236 |
+
doctor_id=availability_data.doctor_id,
|
| 237 |
+
doctor_name=doctor['full_name'],
|
| 238 |
+
day_of_week=availability_doc["day_of_week"],
|
| 239 |
+
start_time=availability_doc["start_time"],
|
| 240 |
+
end_time=availability_doc["end_time"],
|
| 241 |
+
is_available=availability_doc["is_available"]
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
@router.get("/availability/{doctor_id}", response_model=List[DoctorAvailabilityResponse])
|
| 245 |
+
async def get_doctor_availability(
|
| 246 |
+
doctor_id: str,
|
| 247 |
+
current_user: dict = Depends(get_current_user),
|
| 248 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 249 |
+
):
|
| 250 |
+
"""Get doctor availability"""
|
| 251 |
+
if not is_valid_object_id(doctor_id):
|
| 252 |
+
raise HTTPException(
|
| 253 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 254 |
+
detail="Invalid doctor ID"
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# Check if doctor exists
|
| 258 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 259 |
+
if not doctor:
|
| 260 |
+
raise HTTPException(
|
| 261 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 262 |
+
detail="Doctor not found"
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
# Get availability
|
| 266 |
+
cursor = db_client.doctor_availability.find({"doctor_id": ObjectId(doctor_id)})
|
| 267 |
+
availabilities = await cursor.to_list(length=None)
|
| 268 |
+
|
| 269 |
+
return [
|
| 270 |
+
DoctorAvailabilityResponse(
|
| 271 |
+
id=str(avail["_id"]),
|
| 272 |
+
doctor_id=doctor_id,
|
| 273 |
+
doctor_name=doctor['full_name'],
|
| 274 |
+
day_of_week=avail["day_of_week"],
|
| 275 |
+
start_time=avail["start_time"],
|
| 276 |
+
end_time=avail["end_time"],
|
| 277 |
+
is_available=avail["is_available"]
|
| 278 |
+
)
|
| 279 |
+
for avail in availabilities
|
| 280 |
+
]
|
| 281 |
+
|
| 282 |
+
# --- AVAILABLE SLOTS ENDPOINTS ---
|
| 283 |
+
|
| 284 |
+
@router.get("/slots/{doctor_id}/{date}", response_model=AvailableSlotsResponse)
|
| 285 |
+
async def get_available_slots(
|
| 286 |
+
doctor_id: str,
|
| 287 |
+
date: date,
|
| 288 |
+
current_user: dict = Depends(get_current_user),
|
| 289 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 290 |
+
):
|
| 291 |
+
"""Get available appointment slots for a doctor on a specific date"""
|
| 292 |
+
if not is_valid_object_id(doctor_id):
|
| 293 |
+
raise HTTPException(
|
| 294 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 295 |
+
detail="Invalid doctor ID"
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
# Check if doctor exists
|
| 299 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(doctor_id)})
|
| 300 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 301 |
+
raise HTTPException(
|
| 302 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 303 |
+
detail="Doctor not found"
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
# Get doctor availability for this day
|
| 307 |
+
weekday = get_weekday_from_date(date)
|
| 308 |
+
availability = await db_client.doctor_availability.find_one({
|
| 309 |
+
"doctor_id": ObjectId(doctor_id),
|
| 310 |
+
"day_of_week": weekday,
|
| 311 |
+
"is_available": True
|
| 312 |
+
})
|
| 313 |
+
|
| 314 |
+
if not availability:
|
| 315 |
+
return AvailableSlotsResponse(
|
| 316 |
+
doctor_id=doctor_id,
|
| 317 |
+
doctor_name=doctor['full_name'],
|
| 318 |
+
specialty=doctor.get('specialty', ''),
|
| 319 |
+
date=date,
|
| 320 |
+
slots=[]
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
# Generate time slots
|
| 324 |
+
time_slots = generate_time_slots(availability["start_time"], availability["end_time"])
|
| 325 |
+
|
| 326 |
+
# Get existing appointments for this date
|
| 327 |
+
existing_appointments = await db_client.appointments.find({
|
| 328 |
+
"doctor_id": ObjectId(doctor_id),
|
| 329 |
+
"date": date,
|
| 330 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 331 |
+
}).to_list(length=None)
|
| 332 |
+
|
| 333 |
+
booked_times = {apt["time"] for apt in existing_appointments}
|
| 334 |
+
|
| 335 |
+
# Create slot responses
|
| 336 |
+
slots = []
|
| 337 |
+
for slot_time in time_slots:
|
| 338 |
+
is_available = slot_time not in booked_times
|
| 339 |
+
appointment_id = None
|
| 340 |
+
if not is_available:
|
| 341 |
+
# Find the appointment ID for this slot
|
| 342 |
+
appointment = next((apt for apt in existing_appointments if apt["time"] == slot_time), None)
|
| 343 |
+
appointment_id = str(appointment["_id"]) if appointment else None
|
| 344 |
+
|
| 345 |
+
slots.append(AppointmentSlot(
|
| 346 |
+
date=date,
|
| 347 |
+
time=slot_time,
|
| 348 |
+
is_available=is_available,
|
| 349 |
+
appointment_id=appointment_id
|
| 350 |
+
))
|
| 351 |
+
|
| 352 |
+
return AvailableSlotsResponse(
|
| 353 |
+
doctor_id=doctor_id,
|
| 354 |
+
doctor_name=doctor['full_name'],
|
| 355 |
+
specialty=doctor.get('specialty', ''),
|
| 356 |
+
date=date,
|
| 357 |
+
slots=slots
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
# --- APPOINTMENT ENDPOINTS ---
|
| 361 |
+
|
| 362 |
+
@router.post("/", response_model=AppointmentResponse, status_code=status.HTTP_201_CREATED)
|
| 363 |
+
async def create_appointment(
|
| 364 |
+
appointment_data: AppointmentCreate,
|
| 365 |
+
current_user: dict = Depends(get_current_user),
|
| 366 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 367 |
+
):
|
| 368 |
+
"""Create a new appointment"""
|
| 369 |
+
print(f"🔍 Creating appointment with data: {appointment_data}")
|
| 370 |
+
print(f"🔍 Current user: {current_user}")
|
| 371 |
+
# Check if user is a patient
|
| 372 |
+
if 'patient' not in current_user.get('roles', []) and current_user.get('role') != 'patient':
|
| 373 |
+
raise HTTPException(
|
| 374 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 375 |
+
detail="Only patients can create appointments"
|
| 376 |
+
)
|
| 377 |
+
|
| 378 |
+
# Validate ObjectIds
|
| 379 |
+
if not is_valid_object_id(appointment_data.patient_id) or not is_valid_object_id(appointment_data.doctor_id):
|
| 380 |
+
raise HTTPException(
|
| 381 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 382 |
+
detail="Invalid patient_id or doctor_id"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Check if patient exists and matches current user
|
| 386 |
+
patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
|
| 387 |
+
if not patient:
|
| 388 |
+
raise HTTPException(
|
| 389 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 390 |
+
detail="Patient not found"
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
if patient['email'] != current_user['email']:
|
| 394 |
+
raise HTTPException(
|
| 395 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 396 |
+
detail="You can only create appointments for yourself"
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
# Check if doctor exists and is a doctor
|
| 400 |
+
doctor = await db_client.users.find_one({"_id": ObjectId(appointment_data.doctor_id)})
|
| 401 |
+
if not doctor or (doctor.get('role') != 'doctor' and 'doctor' not in doctor.get('roles', [])):
|
| 402 |
+
raise HTTPException(
|
| 403 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 404 |
+
detail="Doctor not found"
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
+
# Convert string date and time to proper objects
|
| 408 |
+
try:
|
| 409 |
+
appointment_date = datetime.strptime(appointment_data.date, '%Y-%m-%d').date()
|
| 410 |
+
appointment_time = datetime.strptime(appointment_data.time, '%H:%M:%S').time()
|
| 411 |
+
# Convert date to datetime for MongoDB compatibility
|
| 412 |
+
appointment_datetime = datetime.combine(appointment_date, appointment_time)
|
| 413 |
+
except ValueError:
|
| 414 |
+
raise HTTPException(
|
| 415 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 416 |
+
detail="Invalid date or time format. Use YYYY-MM-DD for date and HH:MM:SS for time"
|
| 417 |
+
)
|
| 418 |
+
|
| 419 |
+
# Check if appointment date is in the future
|
| 420 |
+
if appointment_datetime <= datetime.now():
|
| 421 |
+
raise HTTPException(
|
| 422 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 423 |
+
detail="Appointment must be scheduled for a future date and time"
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# Check if slot is available
|
| 427 |
+
existing_appointment = await db_client.appointments.find_one({
|
| 428 |
+
"doctor_id": ObjectId(appointment_data.doctor_id),
|
| 429 |
+
"date": appointment_data.date,
|
| 430 |
+
"time": appointment_data.time,
|
| 431 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 432 |
+
})
|
| 433 |
+
|
| 434 |
+
if existing_appointment:
|
| 435 |
+
raise HTTPException(
|
| 436 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 437 |
+
detail="This time slot is already booked"
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
# Create appointment
|
| 441 |
+
appointment_doc = {
|
| 442 |
+
"patient_id": ObjectId(appointment_data.patient_id),
|
| 443 |
+
"doctor_id": ObjectId(appointment_data.doctor_id),
|
| 444 |
+
"date": appointment_data.date, # Store as string
|
| 445 |
+
"time": appointment_data.time, # Store as string
|
| 446 |
+
"datetime": appointment_datetime, # Store full datetime for easier querying
|
| 447 |
+
"type": appointment_data.type,
|
| 448 |
+
"status": AppointmentStatus.PENDING,
|
| 449 |
+
"reason": appointment_data.reason,
|
| 450 |
+
"notes": appointment_data.notes,
|
| 451 |
+
"duration": appointment_data.duration,
|
| 452 |
+
"created_at": datetime.now(),
|
| 453 |
+
"updated_at": datetime.now()
|
| 454 |
+
}
|
| 455 |
+
|
| 456 |
+
result = await db_client.appointments.insert_one(appointment_doc)
|
| 457 |
+
appointment_doc["_id"] = result.inserted_id
|
| 458 |
+
|
| 459 |
+
# If appointment is created with confirmed status, assign patient to doctor
|
| 460 |
+
if appointment_data.status == "confirmed":
|
| 461 |
+
try:
|
| 462 |
+
# Get patient details from users collection
|
| 463 |
+
patient = await db_client.users.find_one({"_id": ObjectId(appointment_data.patient_id)})
|
| 464 |
+
if patient:
|
| 465 |
+
# Check if patient already exists in patients collection
|
| 466 |
+
existing_patient = await patients_collection.find_one({
|
| 467 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 468 |
+
})
|
| 469 |
+
|
| 470 |
+
if existing_patient:
|
| 471 |
+
# Update existing patient to assign to this doctor
|
| 472 |
+
await patients_collection.update_one(
|
| 473 |
+
{"_id": existing_patient["_id"]},
|
| 474 |
+
{
|
| 475 |
+
"$set": {
|
| 476 |
+
"assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
|
| 477 |
+
"updated_at": datetime.now()
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
)
|
| 481 |
+
else:
|
| 482 |
+
# Create new patient record in patients collection
|
| 483 |
+
patient_doc = {
|
| 484 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 485 |
+
"full_name": patient.get("full_name", ""),
|
| 486 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 487 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 488 |
+
"address": patient.get("address"),
|
| 489 |
+
"city": patient.get("city"),
|
| 490 |
+
"state": patient.get("state"),
|
| 491 |
+
"postal_code": patient.get("postal_code"),
|
| 492 |
+
"country": patient.get("country"),
|
| 493 |
+
"national_id": patient.get("national_id"),
|
| 494 |
+
"blood_type": patient.get("blood_type"),
|
| 495 |
+
"allergies": patient.get("allergies", []),
|
| 496 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 497 |
+
"medications": patient.get("medications", []),
|
| 498 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 499 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 500 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 501 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 502 |
+
"notes": patient.get("notes", []),
|
| 503 |
+
"source": "direct", # Patient came through appointment booking
|
| 504 |
+
"status": "active",
|
| 505 |
+
"assigned_doctor_id": str(appointment_data.doctor_id), # Convert to string
|
| 506 |
+
"registration_date": datetime.now(),
|
| 507 |
+
"created_at": datetime.now(),
|
| 508 |
+
"updated_at": datetime.now()
|
| 509 |
+
}
|
| 510 |
+
await patients_collection.insert_one(patient_doc)
|
| 511 |
+
|
| 512 |
+
print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment_data.doctor_id}")
|
| 513 |
+
except Exception as e:
|
| 514 |
+
print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
|
| 515 |
+
# Don't fail the appointment creation if patient assignment fails
|
| 516 |
+
|
| 517 |
+
# Return appointment with patient and doctor names
|
| 518 |
+
return AppointmentResponse(
|
| 519 |
+
id=str(appointment_doc["_id"]),
|
| 520 |
+
patient_id=appointment_data.patient_id,
|
| 521 |
+
doctor_id=appointment_data.doctor_id,
|
| 522 |
+
patient_name=patient['full_name'],
|
| 523 |
+
doctor_name=doctor['full_name'],
|
| 524 |
+
date=appointment_date, # Convert back to date object
|
| 525 |
+
time=appointment_time, # Convert back to time object
|
| 526 |
+
type=appointment_doc["type"],
|
| 527 |
+
status=appointment_doc["status"],
|
| 528 |
+
reason=appointment_doc["reason"],
|
| 529 |
+
notes=appointment_doc["notes"],
|
| 530 |
+
duration=appointment_doc["duration"],
|
| 531 |
+
created_at=appointment_doc["created_at"],
|
| 532 |
+
updated_at=appointment_doc["updated_at"]
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
@router.get("/", response_model=AppointmentListResponse)
|
| 536 |
+
async def get_appointments(
|
| 537 |
+
page: int = Query(1, ge=1),
|
| 538 |
+
limit: int = Query(10, ge=1, le=100),
|
| 539 |
+
status_filter: Optional[AppointmentStatus] = Query(None),
|
| 540 |
+
date_from: Optional[date] = Query(None),
|
| 541 |
+
date_to: Optional[date] = Query(None),
|
| 542 |
+
current_user: dict = Depends(get_current_user),
|
| 543 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 544 |
+
):
|
| 545 |
+
"""Get appointments based on user role"""
|
| 546 |
+
skip = (page - 1) * limit
|
| 547 |
+
|
| 548 |
+
# Build filter based on user role
|
| 549 |
+
if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 550 |
+
# Patients see their own appointments
|
| 551 |
+
filter_query = {"patient_id": ObjectId(current_user["_id"])}
|
| 552 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 553 |
+
# Doctors see appointments assigned to them
|
| 554 |
+
filter_query = {"doctor_id": ObjectId(current_user["_id"])}
|
| 555 |
+
elif 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 556 |
+
# Admins see all appointments
|
| 557 |
+
filter_query = {}
|
| 558 |
+
else:
|
| 559 |
+
raise HTTPException(
|
| 560 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 561 |
+
detail="Insufficient permissions"
|
| 562 |
+
)
|
| 563 |
+
|
| 564 |
+
# Add status filter
|
| 565 |
+
if status_filter:
|
| 566 |
+
filter_query["status"] = status_filter
|
| 567 |
+
|
| 568 |
+
# Add date range filter
|
| 569 |
+
if date_from or date_to:
|
| 570 |
+
date_filter = {}
|
| 571 |
+
if date_from:
|
| 572 |
+
date_filter["$gte"] = date_from
|
| 573 |
+
if date_to:
|
| 574 |
+
date_filter["$lte"] = date_to
|
| 575 |
+
filter_query["date"] = date_filter
|
| 576 |
+
|
| 577 |
+
# Get appointments
|
| 578 |
+
cursor = db_client.appointments.find(filter_query).skip(skip).limit(limit).sort("date", -1)
|
| 579 |
+
appointments = await cursor.to_list(length=limit)
|
| 580 |
+
|
| 581 |
+
print(f"🔍 Found {len(appointments)} appointments")
|
| 582 |
+
for i, apt in enumerate(appointments):
|
| 583 |
+
print(f"🔍 Appointment {i}: {apt}")
|
| 584 |
+
|
| 585 |
+
# Get total count
|
| 586 |
+
total = await db_client.appointments.count_documents(filter_query)
|
| 587 |
+
|
| 588 |
+
# Get patient and doctor names
|
| 589 |
+
appointment_responses = []
|
| 590 |
+
for apt in appointments:
|
| 591 |
+
patient = await db_client.users.find_one({"_id": apt["patient_id"]})
|
| 592 |
+
doctor = await db_client.users.find_one({"_id": apt["doctor_id"]})
|
| 593 |
+
|
| 594 |
+
# Convert string date/time back to objects for response
|
| 595 |
+
apt_date = datetime.strptime(apt["date"], '%Y-%m-%d').date() if isinstance(apt["date"], str) else apt["date"]
|
| 596 |
+
apt_time = datetime.strptime(apt["time"], '%H:%M:%S').time() if isinstance(apt["time"], str) else apt["time"]
|
| 597 |
+
|
| 598 |
+
appointment_responses.append(AppointmentResponse(
|
| 599 |
+
id=str(apt["_id"]),
|
| 600 |
+
patient_id=str(apt["patient_id"]),
|
| 601 |
+
doctor_id=str(apt["doctor_id"]),
|
| 602 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 603 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 604 |
+
date=apt_date,
|
| 605 |
+
time=apt_time,
|
| 606 |
+
type=apt.get("type", "consultation"), # Default to consultation if missing
|
| 607 |
+
status=apt.get("status", "pending"), # Default to pending if missing
|
| 608 |
+
reason=apt.get("reason"),
|
| 609 |
+
notes=apt.get("notes"),
|
| 610 |
+
duration=apt.get("duration", 30),
|
| 611 |
+
created_at=apt.get("created_at", datetime.now()),
|
| 612 |
+
updated_at=apt.get("updated_at", datetime.now())
|
| 613 |
+
))
|
| 614 |
+
|
| 615 |
+
return AppointmentListResponse(
|
| 616 |
+
appointments=appointment_responses,
|
| 617 |
+
total=total,
|
| 618 |
+
page=page,
|
| 619 |
+
limit=limit
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
@router.get("/{appointment_id}", response_model=AppointmentResponse)
|
| 623 |
+
async def get_appointment(
|
| 624 |
+
appointment_id: str,
|
| 625 |
+
current_user: dict = Depends(get_current_user),
|
| 626 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 627 |
+
):
|
| 628 |
+
"""Get a specific appointment"""
|
| 629 |
+
if not is_valid_object_id(appointment_id):
|
| 630 |
+
raise HTTPException(
|
| 631 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 632 |
+
detail="Invalid appointment ID"
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 636 |
+
if not appointment:
|
| 637 |
+
raise HTTPException(
|
| 638 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 639 |
+
detail="Appointment not found"
|
| 640 |
+
)
|
| 641 |
+
|
| 642 |
+
# Check permissions
|
| 643 |
+
if 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 644 |
+
if appointment["patient_id"] != ObjectId(current_user["_id"]):
|
| 645 |
+
raise HTTPException(
|
| 646 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 647 |
+
detail="You can only view your own appointments"
|
| 648 |
+
)
|
| 649 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 650 |
+
if appointment["doctor_id"] != ObjectId(current_user["_id"]):
|
| 651 |
+
raise HTTPException(
|
| 652 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 653 |
+
detail="You can only view appointments assigned to you"
|
| 654 |
+
)
|
| 655 |
+
elif (current_user.get('role') != 'admin' and 'admin' not in current_user.get('roles', [])):
|
| 656 |
+
raise HTTPException(
|
| 657 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 658 |
+
detail="Insufficient permissions"
|
| 659 |
+
)
|
| 660 |
+
|
| 661 |
+
# Get patient and doctor names
|
| 662 |
+
patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
|
| 663 |
+
doctor = await db_client.users.find_one({"_id": appointment["doctor_id"]})
|
| 664 |
+
|
| 665 |
+
# Convert string date/time back to objects for response
|
| 666 |
+
apt_date = datetime.strptime(appointment["date"], '%Y-%m-%d').date() if isinstance(appointment["date"], str) else appointment["date"]
|
| 667 |
+
apt_time = datetime.strptime(appointment["time"], '%H:%M:%S').time() if isinstance(appointment["time"], str) else appointment["time"]
|
| 668 |
+
|
| 669 |
+
return AppointmentResponse(
|
| 670 |
+
id=str(appointment["_id"]),
|
| 671 |
+
patient_id=str(appointment["patient_id"]),
|
| 672 |
+
doctor_id=str(appointment["doctor_id"]),
|
| 673 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 674 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 675 |
+
date=apt_date,
|
| 676 |
+
time=apt_time,
|
| 677 |
+
type=appointment.get("type", "consultation"), # Default to consultation if missing
|
| 678 |
+
status=appointment.get("status", "pending"), # Default to pending if missing
|
| 679 |
+
reason=appointment.get("reason"),
|
| 680 |
+
notes=appointment.get("notes"),
|
| 681 |
+
duration=appointment.get("duration", 30),
|
| 682 |
+
created_at=appointment.get("created_at", datetime.now()),
|
| 683 |
+
updated_at=appointment.get("updated_at", datetime.now())
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
@router.put("/{appointment_id}", response_model=AppointmentResponse)
|
| 687 |
+
async def update_appointment(
|
| 688 |
+
appointment_id: str,
|
| 689 |
+
appointment_data: AppointmentUpdate,
|
| 690 |
+
current_user: dict = Depends(get_current_user),
|
| 691 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 692 |
+
):
|
| 693 |
+
"""Update an appointment"""
|
| 694 |
+
if not is_valid_object_id(appointment_id):
|
| 695 |
+
raise HTTPException(
|
| 696 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 697 |
+
detail="Invalid appointment ID"
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 701 |
+
if not appointment:
|
| 702 |
+
raise HTTPException(
|
| 703 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 704 |
+
detail="Appointment not found"
|
| 705 |
+
)
|
| 706 |
+
|
| 707 |
+
# Check permissions
|
| 708 |
+
can_update = False
|
| 709 |
+
if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 710 |
+
can_update = True
|
| 711 |
+
elif 'doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor':
|
| 712 |
+
if appointment["doctor_id"] == ObjectId(current_user["_id"]):
|
| 713 |
+
can_update = True
|
| 714 |
+
elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 715 |
+
if appointment["patient_id"] == ObjectId(current_user["_id"]):
|
| 716 |
+
# Patients can only update certain fields
|
| 717 |
+
can_update = True
|
| 718 |
+
|
| 719 |
+
if not can_update:
|
| 720 |
+
raise HTTPException(
|
| 721 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 722 |
+
detail="You can only update appointments you're involved with"
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
# Build update data
|
| 726 |
+
update_data = {"updated_at": datetime.now()}
|
| 727 |
+
|
| 728 |
+
if appointment_data.date is not None:
|
| 729 |
+
try:
|
| 730 |
+
# Store as string for MongoDB compatibility
|
| 731 |
+
update_data["date"] = appointment_data.date
|
| 732 |
+
except ValueError:
|
| 733 |
+
raise HTTPException(
|
| 734 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 735 |
+
detail="Invalid date format. Use YYYY-MM-DD"
|
| 736 |
+
)
|
| 737 |
+
if appointment_data.time is not None:
|
| 738 |
+
try:
|
| 739 |
+
# Store as string for MongoDB compatibility
|
| 740 |
+
update_data["time"] = appointment_data.time
|
| 741 |
+
except ValueError:
|
| 742 |
+
raise HTTPException(
|
| 743 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 744 |
+
detail="Invalid time format. Use HH:MM:SS"
|
| 745 |
+
)
|
| 746 |
+
if appointment_data.type is not None:
|
| 747 |
+
update_data["type"] = appointment_data.type
|
| 748 |
+
if appointment_data.reason is not None:
|
| 749 |
+
update_data["reason"] = appointment_data.reason
|
| 750 |
+
if appointment_data.notes is not None:
|
| 751 |
+
update_data["notes"] = appointment_data.notes
|
| 752 |
+
if appointment_data.duration is not None:
|
| 753 |
+
update_data["duration"] = appointment_data.duration
|
| 754 |
+
|
| 755 |
+
# Only doctors and admins can update status
|
| 756 |
+
if appointment_data.status is not None and (('doctor' in current_user.get('roles', []) or current_user.get('role') == 'doctor') or ('admin' in current_user.get('roles', []) or current_user.get('role') == 'admin')):
|
| 757 |
+
update_data["status"] = appointment_data.status
|
| 758 |
+
|
| 759 |
+
# Check for conflicts if date/time is being updated
|
| 760 |
+
if appointment_data.date is not None or appointment_data.time is not None:
|
| 761 |
+
new_date = update_data.get("date") or appointment["date"]
|
| 762 |
+
new_time = update_data.get("time") or appointment["time"]
|
| 763 |
+
|
| 764 |
+
existing_appointment = await db_client.appointments.find_one({
|
| 765 |
+
"_id": {"$ne": ObjectId(appointment_id)},
|
| 766 |
+
"doctor_id": appointment["doctor_id"],
|
| 767 |
+
"date": new_date,
|
| 768 |
+
"time": new_time,
|
| 769 |
+
"status": {"$in": ["pending", "confirmed"]}
|
| 770 |
+
})
|
| 771 |
+
|
| 772 |
+
if existing_appointment:
|
| 773 |
+
raise HTTPException(
|
| 774 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 775 |
+
detail="This time slot is already booked"
|
| 776 |
+
)
|
| 777 |
+
|
| 778 |
+
# Update appointment
|
| 779 |
+
await db_client.appointments.update_one(
|
| 780 |
+
{"_id": ObjectId(appointment_id)},
|
| 781 |
+
{"$set": update_data}
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
# If appointment status is being changed to confirmed, assign patient to doctor
|
| 785 |
+
if appointment_data.status == "confirmed":
|
| 786 |
+
try:
|
| 787 |
+
# Get patient details from users collection
|
| 788 |
+
patient = await db_client.users.find_one({"_id": appointment["patient_id"]})
|
| 789 |
+
if patient:
|
| 790 |
+
# Check if patient already exists in patients collection
|
| 791 |
+
existing_patient = await patients_collection.find_one({
|
| 792 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"])
|
| 793 |
+
})
|
| 794 |
+
|
| 795 |
+
if existing_patient:
|
| 796 |
+
# Update existing patient to assign to this doctor
|
| 797 |
+
await patients_collection.update_one(
|
| 798 |
+
{"_id": existing_patient["_id"]},
|
| 799 |
+
{
|
| 800 |
+
"$set": {
|
| 801 |
+
"assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
|
| 802 |
+
"updated_at": datetime.now()
|
| 803 |
+
}
|
| 804 |
+
}
|
| 805 |
+
)
|
| 806 |
+
else:
|
| 807 |
+
# Create new patient record in patients collection
|
| 808 |
+
patient_doc = {
|
| 809 |
+
"fhir_id": patient.get("fhir_id") or str(patient["_id"]),
|
| 810 |
+
"full_name": patient.get("full_name", ""),
|
| 811 |
+
"date_of_birth": patient.get("date_of_birth") or datetime.now().strftime('%Y-%m-%d'), # Store as string
|
| 812 |
+
"gender": patient.get("gender") or "unknown", # Provide default
|
| 813 |
+
"address": patient.get("address"),
|
| 814 |
+
"city": patient.get("city"),
|
| 815 |
+
"state": patient.get("state"),
|
| 816 |
+
"postal_code": patient.get("postal_code"),
|
| 817 |
+
"country": patient.get("country"),
|
| 818 |
+
"national_id": patient.get("national_id"),
|
| 819 |
+
"blood_type": patient.get("blood_type"),
|
| 820 |
+
"allergies": patient.get("allergies", []),
|
| 821 |
+
"chronic_conditions": patient.get("chronic_conditions", []),
|
| 822 |
+
"medications": patient.get("medications", []),
|
| 823 |
+
"emergency_contact_name": patient.get("emergency_contact_name"),
|
| 824 |
+
"emergency_contact_phone": patient.get("emergency_contact_phone"),
|
| 825 |
+
"insurance_provider": patient.get("insurance_provider"),
|
| 826 |
+
"insurance_policy_number": patient.get("insurance_policy_number"),
|
| 827 |
+
"notes": patient.get("notes", []),
|
| 828 |
+
"source": "direct", # Patient came through appointment booking
|
| 829 |
+
"status": "active",
|
| 830 |
+
"assigned_doctor_id": str(appointment["doctor_id"]), # Convert to string
|
| 831 |
+
"registration_date": datetime.now(),
|
| 832 |
+
"created_at": datetime.now(),
|
| 833 |
+
"updated_at": datetime.now()
|
| 834 |
+
}
|
| 835 |
+
await patients_collection.insert_one(patient_doc)
|
| 836 |
+
|
| 837 |
+
print(f"✅ Patient {patient.get('full_name', 'Unknown')} assigned to doctor {appointment['doctor_id']}")
|
| 838 |
+
except Exception as e:
|
| 839 |
+
print(f"⚠️ Warning: Failed to assign patient to doctor: {str(e)}")
|
| 840 |
+
# Don't fail the appointment update if patient assignment fails
|
| 841 |
+
|
| 842 |
+
# Get updated appointment
|
| 843 |
+
updated_appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 844 |
+
|
| 845 |
+
# Get patient and doctor names
|
| 846 |
+
patient = await db_client.users.find_one({"_id": updated_appointment["patient_id"]})
|
| 847 |
+
doctor = await db_client.users.find_one({"_id": updated_appointment["doctor_id"]})
|
| 848 |
+
|
| 849 |
+
# Convert string date/time back to objects for response
|
| 850 |
+
apt_date = datetime.strptime(updated_appointment["date"], '%Y-%m-%d').date() if isinstance(updated_appointment["date"], str) else updated_appointment["date"]
|
| 851 |
+
apt_time = datetime.strptime(updated_appointment["time"], '%H:%M:%S').time() if isinstance(updated_appointment["time"], str) else updated_appointment["time"]
|
| 852 |
+
|
| 853 |
+
return AppointmentResponse(
|
| 854 |
+
id=str(updated_appointment["_id"]),
|
| 855 |
+
patient_id=str(updated_appointment["patient_id"]),
|
| 856 |
+
doctor_id=str(updated_appointment["doctor_id"]),
|
| 857 |
+
patient_name=patient['full_name'] if patient else "Unknown Patient",
|
| 858 |
+
doctor_name=doctor['full_name'] if doctor else "Unknown Doctor",
|
| 859 |
+
date=apt_date,
|
| 860 |
+
time=apt_time,
|
| 861 |
+
type=updated_appointment.get("type", "consultation"), # Default to consultation if missing
|
| 862 |
+
status=updated_appointment.get("status", "pending"), # Default to pending if missing
|
| 863 |
+
reason=updated_appointment.get("reason"),
|
| 864 |
+
notes=updated_appointment.get("notes"),
|
| 865 |
+
duration=updated_appointment.get("duration", 30),
|
| 866 |
+
created_at=updated_appointment.get("created_at", datetime.now()),
|
| 867 |
+
updated_at=updated_appointment.get("updated_at", datetime.now())
|
| 868 |
+
)
|
| 869 |
+
|
| 870 |
+
@router.delete("/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 871 |
+
async def delete_appointment(
|
| 872 |
+
appointment_id: str,
|
| 873 |
+
current_user: dict = Depends(get_current_user),
|
| 874 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 875 |
+
):
|
| 876 |
+
"""Delete an appointment"""
|
| 877 |
+
if not is_valid_object_id(appointment_id):
|
| 878 |
+
raise HTTPException(
|
| 879 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 880 |
+
detail="Invalid appointment ID"
|
| 881 |
+
)
|
| 882 |
+
|
| 883 |
+
appointment = await db_client.appointments.find_one({"_id": ObjectId(appointment_id)})
|
| 884 |
+
if not appointment:
|
| 885 |
+
raise HTTPException(
|
| 886 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 887 |
+
detail="Appointment not found"
|
| 888 |
+
)
|
| 889 |
+
|
| 890 |
+
# Check permissions
|
| 891 |
+
can_delete = False
|
| 892 |
+
if 'admin' in current_user.get('roles', []) or current_user.get('role') == 'admin':
|
| 893 |
+
can_delete = True
|
| 894 |
+
elif 'patient' in current_user.get('roles', []) or current_user.get('role') == 'patient':
|
| 895 |
+
if appointment["patient_id"] == ObjectId(current_user["_id"]):
|
| 896 |
+
can_delete = True
|
| 897 |
+
|
| 898 |
+
if not can_delete:
|
| 899 |
+
raise HTTPException(
|
| 900 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 901 |
+
detail="You can only delete your own appointments"
|
| 902 |
+
)
|
| 903 |
+
|
| 904 |
+
await db_client.appointments.delete_one({"_id": ObjectId(appointment_id)})
|
routes/auth.py
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status, Request, Query
|
| 2 |
+
from typing import Optional
|
| 3 |
+
from fastapi.security import OAuth2PasswordRequestForm
|
| 4 |
+
from db.mongo import users_collection
|
| 5 |
+
from core.security import hash_password, verify_password, create_access_token, get_current_user
|
| 6 |
+
from models.schemas import (
|
| 7 |
+
SignupForm,
|
| 8 |
+
TokenResponse,
|
| 9 |
+
DoctorCreate,
|
| 10 |
+
AdminCreate,
|
| 11 |
+
ProfileUpdate,
|
| 12 |
+
PasswordChange,
|
| 13 |
+
AdminUserUpdate,
|
| 14 |
+
AdminPasswordReset,
|
| 15 |
+
)
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
# Configure logging
|
| 20 |
+
logging.basicConfig(
|
| 21 |
+
level=logging.INFO,
|
| 22 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 23 |
+
)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
router = APIRouter()
|
| 27 |
+
|
| 28 |
+
@router.post("/signup", status_code=status.HTTP_201_CREATED)
|
| 29 |
+
async def signup(data: SignupForm):
|
| 30 |
+
"""
|
| 31 |
+
Patient registration endpoint - only patients can register through signup
|
| 32 |
+
Doctors and admins must be created by existing admins
|
| 33 |
+
"""
|
| 34 |
+
logger.info(f"Patient signup attempt for email: {data.email}")
|
| 35 |
+
logger.info(f"Received signup data: {data.dict()}")
|
| 36 |
+
email = data.email.lower().strip()
|
| 37 |
+
existing = await users_collection.find_one({"email": email})
|
| 38 |
+
if existing:
|
| 39 |
+
logger.warning(f"Signup failed: Email already exists: {email}")
|
| 40 |
+
raise HTTPException(
|
| 41 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 42 |
+
detail="Email already exists"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
hashed_pw = hash_password(data.password)
|
| 46 |
+
user_doc = {
|
| 47 |
+
"email": email,
|
| 48 |
+
"full_name": data.full_name.strip(),
|
| 49 |
+
"password": hashed_pw,
|
| 50 |
+
"roles": ["patient"], # Only patients can register through signup
|
| 51 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 52 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 53 |
+
"device_token": "" # Default empty device token for patients
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
result = await users_collection.insert_one(user_doc)
|
| 58 |
+
logger.info(f"User created successfully: {email}")
|
| 59 |
+
return {
|
| 60 |
+
"status": "success",
|
| 61 |
+
"id": str(result.inserted_id),
|
| 62 |
+
"email": email
|
| 63 |
+
}
|
| 64 |
+
except Exception as e:
|
| 65 |
+
logger.error(f"Failed to create user {email}: {str(e)}")
|
| 66 |
+
raise HTTPException(
|
| 67 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 68 |
+
detail=f"Failed to create user: {str(e)}"
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
@router.post("/admin/doctors", status_code=status.HTTP_201_CREATED)
|
| 72 |
+
async def create_doctor(
|
| 73 |
+
data: DoctorCreate,
|
| 74 |
+
current_user: dict = Depends(get_current_user)
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Create doctor account - only admins can create doctor accounts
|
| 78 |
+
"""
|
| 79 |
+
logger.info(f"Doctor creation attempt by {current_user.get('email')}")
|
| 80 |
+
if 'admin' not in current_user.get('roles', []):
|
| 81 |
+
logger.warning(f"Unauthorized doctor creation attempt by {current_user.get('email')}")
|
| 82 |
+
raise HTTPException(
|
| 83 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 84 |
+
detail="Only admins can create doctor accounts"
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
email = data.email.lower().strip()
|
| 88 |
+
existing = await users_collection.find_one({"email": email})
|
| 89 |
+
if existing:
|
| 90 |
+
logger.warning(f"Doctor creation failed: Email already exists: {email}")
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 93 |
+
detail="Email already exists"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
hashed_pw = hash_password(data.password)
|
| 97 |
+
doctor_doc = {
|
| 98 |
+
"email": email,
|
| 99 |
+
"full_name": data.full_name.strip(),
|
| 100 |
+
"password": hashed_pw,
|
| 101 |
+
"roles": data.roles, # Support multiple roles
|
| 102 |
+
"specialty": data.specialty,
|
| 103 |
+
"license_number": data.license_number,
|
| 104 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 105 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 106 |
+
"device_token": data.device_token or ""
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
result = await users_collection.insert_one(doctor_doc)
|
| 111 |
+
logger.info(f"Doctor created successfully: {email}")
|
| 112 |
+
return {
|
| 113 |
+
"status": "success",
|
| 114 |
+
"id": str(result.inserted_id),
|
| 115 |
+
"email": email
|
| 116 |
+
}
|
| 117 |
+
except Exception as e:
|
| 118 |
+
logger.error(f"Failed to create doctor {email}: {str(e)}")
|
| 119 |
+
raise HTTPException(
|
| 120 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 121 |
+
detail=f"Failed to create doctor: {str(e)}"
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
@router.post("/admin/admins", status_code=status.HTTP_201_CREATED)
|
| 125 |
+
async def create_admin(
|
| 126 |
+
data: AdminCreate,
|
| 127 |
+
current_user: dict = Depends(get_current_user)
|
| 128 |
+
):
|
| 129 |
+
"""
|
| 130 |
+
Create admin account - only existing admins can create new admin accounts
|
| 131 |
+
"""
|
| 132 |
+
logger.info(f"Admin creation attempt by {current_user.get('email')}")
|
| 133 |
+
if 'admin' not in current_user.get('roles', []):
|
| 134 |
+
logger.warning(f"Unauthorized admin creation attempt by {current_user.get('email')}")
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 137 |
+
detail="Only admins can create admin accounts"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
email = data.email.lower().strip()
|
| 141 |
+
existing = await users_collection.find_one({"email": email})
|
| 142 |
+
if existing:
|
| 143 |
+
logger.warning(f"Admin creation failed: Email already exists: {email}")
|
| 144 |
+
raise HTTPException(
|
| 145 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 146 |
+
detail="Email already exists"
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
hashed_pw = hash_password(data.password)
|
| 150 |
+
admin_doc = {
|
| 151 |
+
"email": email,
|
| 152 |
+
"full_name": data.full_name.strip(),
|
| 153 |
+
"password": hashed_pw,
|
| 154 |
+
"roles": data.roles, # Support multiple roles
|
| 155 |
+
"created_at": datetime.utcnow().isoformat(),
|
| 156 |
+
"updated_at": datetime.utcnow().isoformat(),
|
| 157 |
+
"device_token": ""
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
try:
|
| 161 |
+
result = await users_collection.insert_one(admin_doc)
|
| 162 |
+
logger.info(f"Admin created successfully: {email}")
|
| 163 |
+
return {
|
| 164 |
+
"status": "success",
|
| 165 |
+
"id": str(result.inserted_id),
|
| 166 |
+
"email": email
|
| 167 |
+
}
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Failed to create admin {email}: {str(e)}")
|
| 170 |
+
raise HTTPException(
|
| 171 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 172 |
+
detail=f"Failed to create admin: {str(e)}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
@router.post("/login", response_model=TokenResponse)
|
| 176 |
+
async def login(form_data: OAuth2PasswordRequestForm = Depends()):
|
| 177 |
+
logger.info(f"Login attempt for email: {form_data.username}")
|
| 178 |
+
user = await users_collection.find_one({"email": form_data.username.lower()})
|
| 179 |
+
if not user or not verify_password(form_data.password, user["password"]):
|
| 180 |
+
logger.warning(f"Login failed for {form_data.username}: Invalid credentials")
|
| 181 |
+
raise HTTPException(
|
| 182 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 183 |
+
detail="Invalid credentials",
|
| 184 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
# Update device token if provided in form_data (e.g., from frontend)
|
| 188 |
+
if hasattr(form_data, 'device_token') and form_data.device_token:
|
| 189 |
+
await users_collection.update_one(
|
| 190 |
+
{"email": user["email"]},
|
| 191 |
+
{"$set": {"device_token": form_data.device_token}}
|
| 192 |
+
)
|
| 193 |
+
logger.info(f"Device token updated for {form_data.username}")
|
| 194 |
+
|
| 195 |
+
access_token = create_access_token(data={"sub": user["email"]})
|
| 196 |
+
logger.info(f"Successful login for {form_data.username}")
|
| 197 |
+
return {
|
| 198 |
+
"access_token": access_token,
|
| 199 |
+
"token_type": "bearer",
|
| 200 |
+
"roles": user.get("roles", ["patient"]) # Return all roles
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
@router.get("/me")
|
| 204 |
+
async def get_me(request: Request, current_user: dict = Depends(get_current_user)):
|
| 205 |
+
logger.info(f"Fetching user profile for {current_user['email']} at {datetime.utcnow().isoformat()}")
|
| 206 |
+
print(f"Headers: {request.headers}")
|
| 207 |
+
try:
|
| 208 |
+
user = await users_collection.find_one({"email": current_user["email"]})
|
| 209 |
+
if not user:
|
| 210 |
+
logger.warning(f"User not found: {current_user['email']}")
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 213 |
+
detail="User not found"
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
# Handle both "role" (singular) and "roles" (array) formats
|
| 217 |
+
user_roles = user.get("roles", [])
|
| 218 |
+
if not user_roles and user.get("role"):
|
| 219 |
+
# If roles array is empty but role field exists, convert to array
|
| 220 |
+
user_roles = [user.get("role")]
|
| 221 |
+
|
| 222 |
+
print(f"🔍 User from DB: {user}")
|
| 223 |
+
print(f"🔍 User roles: {user_roles}")
|
| 224 |
+
|
| 225 |
+
response = {
|
| 226 |
+
"id": str(user["_id"]),
|
| 227 |
+
"email": user["email"],
|
| 228 |
+
"full_name": user.get("full_name", ""),
|
| 229 |
+
"roles": user_roles, # Return all roles
|
| 230 |
+
"specialty": user.get("specialty"),
|
| 231 |
+
"created_at": user.get("created_at"),
|
| 232 |
+
"updated_at": user.get("updated_at"),
|
| 233 |
+
"device_token": user.get("device_token", "") # Include device token in response
|
| 234 |
+
}
|
| 235 |
+
logger.info(f"User profile retrieved for {current_user['email']} at {datetime.utcnow().isoformat()}")
|
| 236 |
+
return response
|
| 237 |
+
except Exception as e:
|
| 238 |
+
logger.error(f"Database error for user {current_user['email']}: {str(e)} at {datetime.utcnow().isoformat()}")
|
| 239 |
+
raise HTTPException(
|
| 240 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 241 |
+
detail=f"Database error: {str(e)}"
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
@router.get("/users")
|
| 245 |
+
async def list_users(
|
| 246 |
+
role: Optional[str] = None,
|
| 247 |
+
search: Optional[str] = Query(None, description="Search by name or email"),
|
| 248 |
+
current_user: dict = Depends(get_current_user)
|
| 249 |
+
):
|
| 250 |
+
"""
|
| 251 |
+
List users - admins can see all users, doctors can see patients, patients can only see themselves
|
| 252 |
+
"""
|
| 253 |
+
logger.info(f"User list request by {current_user.get('email')} with role filter: {role}")
|
| 254 |
+
|
| 255 |
+
# Build query based on user role and requested filter
|
| 256 |
+
query = {}
|
| 257 |
+
if role:
|
| 258 |
+
# support both role singlular and roles array in historical docs
|
| 259 |
+
query["roles"] = {"$in": [role]}
|
| 260 |
+
if search:
|
| 261 |
+
query["$or"] = [
|
| 262 |
+
{"full_name": {"$regex": search, "$options": "i"}},
|
| 263 |
+
{"email": {"$regex": search, "$options": "i"}},
|
| 264 |
+
]
|
| 265 |
+
|
| 266 |
+
# Role-based access control
|
| 267 |
+
if 'admin' in current_user.get('roles', []):
|
| 268 |
+
# Admins can see all users
|
| 269 |
+
pass
|
| 270 |
+
elif 'doctor' in current_user.get('roles', []):
|
| 271 |
+
# Doctors can only see patients
|
| 272 |
+
query["roles"] = {"$in": ["patient"]}
|
| 273 |
+
elif 'patient' in current_user.get('roles', []):
|
| 274 |
+
# Patients can only see themselves
|
| 275 |
+
query["email"] = current_user.get('email')
|
| 276 |
+
|
| 277 |
+
try:
|
| 278 |
+
users = await users_collection.find(query).limit(500).to_list(length=500)
|
| 279 |
+
# Remove sensitive information
|
| 280 |
+
for user in users:
|
| 281 |
+
user["id"] = str(user["_id"])
|
| 282 |
+
del user["_id"]
|
| 283 |
+
del user["password"]
|
| 284 |
+
user.pop("device_token", None) # Safely remove device_token if it exists
|
| 285 |
+
|
| 286 |
+
logger.info(f"Retrieved {len(users)} users for {current_user.get('email')}")
|
| 287 |
+
return users
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error retrieving users: {str(e)}")
|
| 290 |
+
raise HTTPException(
|
| 291 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 292 |
+
detail=f"Failed to retrieve users: {str(e)}"
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
@router.put("/admin/users/{user_id}")
|
| 296 |
+
async def admin_update_user(
|
| 297 |
+
user_id: str,
|
| 298 |
+
data: AdminUserUpdate,
|
| 299 |
+
current_user: dict = Depends(get_current_user)
|
| 300 |
+
):
|
| 301 |
+
if 'admin' not in current_user.get('roles', []):
|
| 302 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 303 |
+
try:
|
| 304 |
+
update_data = {k: v for k, v in data.dict().items() if v is not None}
|
| 305 |
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 306 |
+
result = await users_collection.update_one({"_id": __import__('bson').ObjectId(user_id)}, {"$set": update_data})
|
| 307 |
+
if result.matched_count == 0:
|
| 308 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 309 |
+
return {"status": "success"}
|
| 310 |
+
except Exception as e:
|
| 311 |
+
raise HTTPException(status_code=500, detail=f"Failed to update user: {str(e)}")
|
| 312 |
+
|
| 313 |
+
@router.delete("/admin/users/{user_id}")
|
| 314 |
+
async def admin_delete_user(
|
| 315 |
+
user_id: str,
|
| 316 |
+
current_user: dict = Depends(get_current_user)
|
| 317 |
+
):
|
| 318 |
+
if 'admin' not in current_user.get('roles', []):
|
| 319 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 320 |
+
try:
|
| 321 |
+
result = await users_collection.delete_one({"_id": __import__('bson').ObjectId(user_id)})
|
| 322 |
+
if result.deleted_count == 0:
|
| 323 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 324 |
+
return {"status": "success"}
|
| 325 |
+
except Exception as e:
|
| 326 |
+
raise HTTPException(status_code=500, detail=f"Failed to delete user: {str(e)}")
|
| 327 |
+
|
| 328 |
+
@router.post("/admin/users/{user_id}/reset-password")
|
| 329 |
+
async def admin_reset_password(
|
| 330 |
+
user_id: str,
|
| 331 |
+
data: AdminPasswordReset,
|
| 332 |
+
current_user: dict = Depends(get_current_user)
|
| 333 |
+
):
|
| 334 |
+
if 'admin' not in current_user.get('roles', []):
|
| 335 |
+
raise HTTPException(status_code=403, detail="Admins only")
|
| 336 |
+
if len(data.new_password) < 6:
|
| 337 |
+
raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
|
| 338 |
+
try:
|
| 339 |
+
hashed = hash_password(data.new_password)
|
| 340 |
+
result = await users_collection.update_one(
|
| 341 |
+
{"_id": __import__('bson').ObjectId(user_id)},
|
| 342 |
+
{"$set": {"password": hashed, "updated_at": datetime.utcnow().isoformat()}}
|
| 343 |
+
)
|
| 344 |
+
if result.matched_count == 0:
|
| 345 |
+
raise HTTPException(status_code=404, detail="User not found")
|
| 346 |
+
return {"status": "success"}
|
| 347 |
+
except Exception as e:
|
| 348 |
+
raise HTTPException(status_code=500, detail=f"Failed to reset password: {str(e)}")
|
| 349 |
+
|
| 350 |
+
@router.put("/profile", status_code=status.HTTP_200_OK)
|
| 351 |
+
async def update_profile(
|
| 352 |
+
data: ProfileUpdate,
|
| 353 |
+
current_user: dict = Depends(get_current_user)
|
| 354 |
+
):
|
| 355 |
+
"""
|
| 356 |
+
Update user profile - users can update their own profile
|
| 357 |
+
"""
|
| 358 |
+
logger.info(f"Profile update attempt by {current_user.get('email')}")
|
| 359 |
+
|
| 360 |
+
# Build update data (only include fields that are provided)
|
| 361 |
+
update_data = {}
|
| 362 |
+
if data.full_name is not None:
|
| 363 |
+
update_data["full_name"] = data.full_name.strip()
|
| 364 |
+
if data.phone is not None:
|
| 365 |
+
update_data["phone"] = data.phone.strip()
|
| 366 |
+
if data.address is not None:
|
| 367 |
+
update_data["address"] = data.address.strip()
|
| 368 |
+
if data.date_of_birth is not None:
|
| 369 |
+
update_data["date_of_birth"] = data.date_of_birth
|
| 370 |
+
if data.gender is not None:
|
| 371 |
+
update_data["gender"] = data.gender.strip()
|
| 372 |
+
if data.specialty is not None:
|
| 373 |
+
update_data["specialty"] = data.specialty.strip()
|
| 374 |
+
if data.license_number is not None:
|
| 375 |
+
update_data["license_number"] = data.license_number.strip()
|
| 376 |
+
|
| 377 |
+
# Add updated timestamp
|
| 378 |
+
update_data["updated_at"] = datetime.utcnow().isoformat()
|
| 379 |
+
|
| 380 |
+
try:
|
| 381 |
+
result = await users_collection.update_one(
|
| 382 |
+
{"email": current_user.get('email')},
|
| 383 |
+
{"$set": update_data}
|
| 384 |
+
)
|
| 385 |
+
|
| 386 |
+
if result.modified_count == 0:
|
| 387 |
+
raise HTTPException(
|
| 388 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 389 |
+
detail="User not found"
|
| 390 |
+
)
|
| 391 |
+
|
| 392 |
+
logger.info(f"Profile updated successfully for {current_user.get('email')}")
|
| 393 |
+
return {
|
| 394 |
+
"status": "success",
|
| 395 |
+
"message": "Profile updated successfully"
|
| 396 |
+
}
|
| 397 |
+
except Exception as e:
|
| 398 |
+
logger.error(f"Failed to update profile for {current_user.get('email')}: {str(e)}")
|
| 399 |
+
raise HTTPException(
|
| 400 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 401 |
+
detail=f"Failed to update profile: {str(e)}"
|
| 402 |
+
)
|
| 403 |
+
|
| 404 |
+
@router.post("/change-password", status_code=status.HTTP_200_OK)
|
| 405 |
+
async def change_password(
|
| 406 |
+
data: PasswordChange,
|
| 407 |
+
current_user: dict = Depends(get_current_user)
|
| 408 |
+
):
|
| 409 |
+
"""
|
| 410 |
+
Change user password - users can change their own password
|
| 411 |
+
"""
|
| 412 |
+
logger.info(f"Password change attempt by {current_user.get('email')}")
|
| 413 |
+
|
| 414 |
+
# Verify current password
|
| 415 |
+
if not verify_password(data.current_password, current_user.get('password')):
|
| 416 |
+
logger.warning(f"Password change failed: incorrect current password for {current_user.get('email')}")
|
| 417 |
+
raise HTTPException(
|
| 418 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 419 |
+
detail="Current password is incorrect"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
# Validate new password
|
| 423 |
+
if len(data.new_password) < 6:
|
| 424 |
+
raise HTTPException(
|
| 425 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 426 |
+
detail="New password must be at least 6 characters long"
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
# Hash new password
|
| 430 |
+
hashed_new_password = hash_password(data.new_password)
|
| 431 |
+
|
| 432 |
+
try:
|
| 433 |
+
result = await users_collection.update_one(
|
| 434 |
+
{"email": current_user.get('email')},
|
| 435 |
+
{
|
| 436 |
+
"$set": {
|
| 437 |
+
"password": hashed_new_password,
|
| 438 |
+
"updated_at": datetime.utcnow().isoformat()
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
)
|
| 442 |
+
|
| 443 |
+
if result.modified_count == 0:
|
| 444 |
+
raise HTTPException(
|
| 445 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 446 |
+
detail="User not found"
|
| 447 |
+
)
|
| 448 |
+
|
| 449 |
+
logger.info(f"Password changed successfully for {current_user.get('email')}")
|
| 450 |
+
return {
|
| 451 |
+
"status": "success",
|
| 452 |
+
"message": "Password changed successfully"
|
| 453 |
+
}
|
| 454 |
+
except Exception as e:
|
| 455 |
+
logger.error(f"Failed to change password for {current_user.get('email')}: {str(e)}")
|
| 456 |
+
raise HTTPException(
|
| 457 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 458 |
+
detail=f"Failed to change password: {str(e)}"
|
| 459 |
+
)
|
| 460 |
+
|
| 461 |
+
# Export the router as 'auth' for api.__init__.py
|
| 462 |
+
auth = router
|
routes/fhir_integration.py
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
import httpx
|
| 4 |
+
import json
|
| 5 |
+
from datetime import datetime
|
| 6 |
+
from ..auth.auth import get_current_user
|
| 7 |
+
from models.schemas import User
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
# HAPI FHIR Test Server URL
|
| 12 |
+
HAPI_FHIR_BASE_URL = "https://hapi.fhir.org/baseR4"
|
| 13 |
+
|
| 14 |
+
class FHIRIntegration:
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.base_url = HAPI_FHIR_BASE_URL
|
| 17 |
+
self.client = httpx.AsyncClient(timeout=30.0)
|
| 18 |
+
|
| 19 |
+
async def search_patients(self, limit: int = 10, offset: int = 0) -> dict:
|
| 20 |
+
"""Search for patients in HAPI FHIR Test Server"""
|
| 21 |
+
try:
|
| 22 |
+
url = f"{self.base_url}/Patient"
|
| 23 |
+
params = {
|
| 24 |
+
"_count": limit,
|
| 25 |
+
"_getpagesoffset": offset
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
response = await self.client.get(url, params=params)
|
| 29 |
+
response.raise_for_status()
|
| 30 |
+
|
| 31 |
+
return response.json()
|
| 32 |
+
except Exception as e:
|
| 33 |
+
raise HTTPException(status_code=500, detail=f"FHIR server error: {str(e)}")
|
| 34 |
+
|
| 35 |
+
async def get_patient_by_id(self, patient_id: str) -> dict:
|
| 36 |
+
"""Get a specific patient by ID from HAPI FHIR Test Server"""
|
| 37 |
+
try:
|
| 38 |
+
url = f"{self.base_url}/Patient/{patient_id}"
|
| 39 |
+
response = await self.client.get(url)
|
| 40 |
+
response.raise_for_status()
|
| 41 |
+
|
| 42 |
+
return response.json()
|
| 43 |
+
except Exception as e:
|
| 44 |
+
raise HTTPException(status_code=404, detail=f"Patient not found: {str(e)}")
|
| 45 |
+
|
| 46 |
+
async def get_patient_observations(self, patient_id: str) -> dict:
|
| 47 |
+
"""Get observations (vital signs, lab results) for a patient"""
|
| 48 |
+
try:
|
| 49 |
+
url = f"{self.base_url}/Observation"
|
| 50 |
+
params = {
|
| 51 |
+
"patient": patient_id,
|
| 52 |
+
"_count": 100
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
response = await self.client.get(url, params=params)
|
| 56 |
+
response.raise_for_status()
|
| 57 |
+
|
| 58 |
+
return response.json()
|
| 59 |
+
except Exception as e:
|
| 60 |
+
raise HTTPException(status_code=500, detail=f"Error fetching observations: {str(e)}")
|
| 61 |
+
|
| 62 |
+
async def get_patient_medications(self, patient_id: str) -> dict:
|
| 63 |
+
"""Get medications for a patient"""
|
| 64 |
+
try:
|
| 65 |
+
url = f"{self.base_url}/MedicationRequest"
|
| 66 |
+
params = {
|
| 67 |
+
"patient": patient_id,
|
| 68 |
+
"_count": 100
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
response = await self.client.get(url, params=params)
|
| 72 |
+
response.raise_for_status()
|
| 73 |
+
|
| 74 |
+
return response.json()
|
| 75 |
+
except Exception as e:
|
| 76 |
+
raise HTTPException(status_code=500, detail=f"Error fetching medications: {str(e)}")
|
| 77 |
+
|
| 78 |
+
async def get_patient_conditions(self, patient_id: str) -> dict:
|
| 79 |
+
"""Get conditions (diagnoses) for a patient"""
|
| 80 |
+
try:
|
| 81 |
+
url = f"{self.base_url}/Condition"
|
| 82 |
+
params = {
|
| 83 |
+
"patient": patient_id,
|
| 84 |
+
"_count": 100
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
response = await self.client.get(url, params=params)
|
| 88 |
+
response.raise_for_status()
|
| 89 |
+
|
| 90 |
+
return response.json()
|
| 91 |
+
except Exception as e:
|
| 92 |
+
raise HTTPException(status_code=500, detail=f"Error fetching conditions: {str(e)}")
|
| 93 |
+
|
| 94 |
+
async def get_patient_encounters(self, patient_id: str) -> dict:
|
| 95 |
+
"""Get encounters (visits) for a patient"""
|
| 96 |
+
try:
|
| 97 |
+
url = f"{self.base_url}/Encounter"
|
| 98 |
+
params = {
|
| 99 |
+
"patient": patient_id,
|
| 100 |
+
"_count": 100
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
response = await self.client.get(url, params=params)
|
| 104 |
+
response.raise_for_status()
|
| 105 |
+
|
| 106 |
+
return response.json()
|
| 107 |
+
except Exception as e:
|
| 108 |
+
raise HTTPException(status_code=500, detail=f"Error fetching encounters: {str(e)}")
|
| 109 |
+
|
| 110 |
+
# Initialize FHIR integration
|
| 111 |
+
fhir_integration = FHIRIntegration()
|
| 112 |
+
|
| 113 |
+
@router.get("/fhir/patients")
|
| 114 |
+
async def get_fhir_patients(
|
| 115 |
+
limit: int = 10,
|
| 116 |
+
offset: int = 0,
|
| 117 |
+
current_user: User = Depends(get_current_user)
|
| 118 |
+
):
|
| 119 |
+
"""Get patients from HAPI FHIR Test Server"""
|
| 120 |
+
try:
|
| 121 |
+
patients_data = await fhir_integration.search_patients(limit, offset)
|
| 122 |
+
|
| 123 |
+
# Transform FHIR patients to our format
|
| 124 |
+
transformed_patients = []
|
| 125 |
+
for patient in patients_data.get("entry", []):
|
| 126 |
+
fhir_patient = patient.get("resource", {})
|
| 127 |
+
|
| 128 |
+
# Extract patient information
|
| 129 |
+
patient_info = {
|
| 130 |
+
"fhir_id": fhir_patient.get("id"),
|
| 131 |
+
"full_name": "",
|
| 132 |
+
"gender": fhir_patient.get("gender", "unknown"),
|
| 133 |
+
"date_of_birth": "",
|
| 134 |
+
"address": "",
|
| 135 |
+
"phone": "",
|
| 136 |
+
"email": ""
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
# Extract name
|
| 140 |
+
if fhir_patient.get("name"):
|
| 141 |
+
name_parts = []
|
| 142 |
+
for name in fhir_patient["name"]:
|
| 143 |
+
if name.get("given"):
|
| 144 |
+
name_parts.extend(name["given"])
|
| 145 |
+
if name.get("family"):
|
| 146 |
+
name_parts.append(name["family"])
|
| 147 |
+
patient_info["full_name"] = " ".join(name_parts)
|
| 148 |
+
|
| 149 |
+
# Extract birth date
|
| 150 |
+
if fhir_patient.get("birthDate"):
|
| 151 |
+
patient_info["date_of_birth"] = fhir_patient["birthDate"]
|
| 152 |
+
|
| 153 |
+
# Extract address
|
| 154 |
+
if fhir_patient.get("address"):
|
| 155 |
+
address_parts = []
|
| 156 |
+
for address in fhir_patient["address"]:
|
| 157 |
+
if address.get("line"):
|
| 158 |
+
address_parts.extend(address["line"])
|
| 159 |
+
if address.get("city"):
|
| 160 |
+
address_parts.append(address["city"])
|
| 161 |
+
if address.get("state"):
|
| 162 |
+
address_parts.append(address["state"])
|
| 163 |
+
if address.get("postalCode"):
|
| 164 |
+
address_parts.append(address["postalCode"])
|
| 165 |
+
patient_info["address"] = ", ".join(address_parts)
|
| 166 |
+
|
| 167 |
+
# Extract contact information
|
| 168 |
+
if fhir_patient.get("telecom"):
|
| 169 |
+
for telecom in fhir_patient["telecom"]:
|
| 170 |
+
if telecom.get("system") == "phone":
|
| 171 |
+
patient_info["phone"] = telecom.get("value", "")
|
| 172 |
+
elif telecom.get("system") == "email":
|
| 173 |
+
patient_info["email"] = telecom.get("value", "")
|
| 174 |
+
|
| 175 |
+
transformed_patients.append(patient_info)
|
| 176 |
+
|
| 177 |
+
return {
|
| 178 |
+
"patients": transformed_patients,
|
| 179 |
+
"total": patients_data.get("total", len(transformed_patients)),
|
| 180 |
+
"count": len(transformed_patients)
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
except Exception as e:
|
| 184 |
+
raise HTTPException(status_code=500, detail=f"Error fetching FHIR patients: {str(e)}")
|
| 185 |
+
|
| 186 |
+
@router.get("/fhir/patients/{patient_id}")
|
| 187 |
+
async def get_fhir_patient_details(
|
| 188 |
+
patient_id: str,
|
| 189 |
+
current_user: User = Depends(get_current_user)
|
| 190 |
+
):
|
| 191 |
+
"""Get detailed patient information from HAPI FHIR Test Server"""
|
| 192 |
+
try:
|
| 193 |
+
# Get basic patient info
|
| 194 |
+
patient_data = await fhir_integration.get_patient_by_id(patient_id)
|
| 195 |
+
|
| 196 |
+
# Get additional patient data
|
| 197 |
+
observations = await fhir_integration.get_patient_observations(patient_id)
|
| 198 |
+
medications = await fhir_integration.get_patient_medications(patient_id)
|
| 199 |
+
conditions = await fhir_integration.get_patient_conditions(patient_id)
|
| 200 |
+
encounters = await fhir_integration.get_patient_encounters(patient_id)
|
| 201 |
+
|
| 202 |
+
# Transform and combine all data
|
| 203 |
+
patient_info = {
|
| 204 |
+
"fhir_id": patient_data.get("id"),
|
| 205 |
+
"full_name": "",
|
| 206 |
+
"gender": patient_data.get("gender", "unknown"),
|
| 207 |
+
"date_of_birth": patient_data.get("birthDate", ""),
|
| 208 |
+
"address": "",
|
| 209 |
+
"phone": "",
|
| 210 |
+
"email": "",
|
| 211 |
+
"observations": [],
|
| 212 |
+
"medications": [],
|
| 213 |
+
"conditions": [],
|
| 214 |
+
"encounters": []
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Extract name
|
| 218 |
+
if patient_data.get("name"):
|
| 219 |
+
name_parts = []
|
| 220 |
+
for name in patient_data["name"]:
|
| 221 |
+
if name.get("given"):
|
| 222 |
+
name_parts.extend(name["given"])
|
| 223 |
+
if name.get("family"):
|
| 224 |
+
name_parts.append(name["family"])
|
| 225 |
+
patient_info["full_name"] = " ".join(name_parts)
|
| 226 |
+
|
| 227 |
+
# Extract address
|
| 228 |
+
if patient_data.get("address"):
|
| 229 |
+
address_parts = []
|
| 230 |
+
for address in patient_data["address"]:
|
| 231 |
+
if address.get("line"):
|
| 232 |
+
address_parts.extend(address["line"])
|
| 233 |
+
if address.get("city"):
|
| 234 |
+
address_parts.append(address["city"])
|
| 235 |
+
if address.get("state"):
|
| 236 |
+
address_parts.append(address["state"])
|
| 237 |
+
if address.get("postalCode"):
|
| 238 |
+
address_parts.append(address["postalCode"])
|
| 239 |
+
patient_info["address"] = ", ".join(address_parts)
|
| 240 |
+
|
| 241 |
+
# Extract contact information
|
| 242 |
+
if patient_data.get("telecom"):
|
| 243 |
+
for telecom in patient_data["telecom"]:
|
| 244 |
+
if telecom.get("system") == "phone":
|
| 245 |
+
patient_info["phone"] = telecom.get("value", "")
|
| 246 |
+
elif telecom.get("system") == "email":
|
| 247 |
+
patient_info["email"] = telecom.get("value", "")
|
| 248 |
+
|
| 249 |
+
# Transform observations
|
| 250 |
+
for obs in observations.get("entry", []):
|
| 251 |
+
resource = obs.get("resource", {})
|
| 252 |
+
if resource.get("code", {}).get("text"):
|
| 253 |
+
patient_info["observations"].append({
|
| 254 |
+
"type": resource["code"]["text"],
|
| 255 |
+
"value": resource.get("valueQuantity", {}).get("value"),
|
| 256 |
+
"unit": resource.get("valueQuantity", {}).get("unit"),
|
| 257 |
+
"date": resource.get("effectiveDateTime", "")
|
| 258 |
+
})
|
| 259 |
+
|
| 260 |
+
# Transform medications
|
| 261 |
+
for med in medications.get("entry", []):
|
| 262 |
+
resource = med.get("resource", {})
|
| 263 |
+
if resource.get("medicationCodeableConcept", {}).get("text"):
|
| 264 |
+
patient_info["medications"].append({
|
| 265 |
+
"name": resource["medicationCodeableConcept"]["text"],
|
| 266 |
+
"status": resource.get("status", ""),
|
| 267 |
+
"prescribed_date": resource.get("authoredOn", ""),
|
| 268 |
+
"dosage": resource.get("dosageInstruction", [{}])[0].get("text", "")
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
# Transform conditions
|
| 272 |
+
for condition in conditions.get("entry", []):
|
| 273 |
+
resource = condition.get("resource", {})
|
| 274 |
+
if resource.get("code", {}).get("text"):
|
| 275 |
+
patient_info["conditions"].append({
|
| 276 |
+
"name": resource["code"]["text"],
|
| 277 |
+
"status": resource.get("clinicalStatus", {}).get("text", ""),
|
| 278 |
+
"onset_date": resource.get("onsetDateTime", ""),
|
| 279 |
+
"severity": resource.get("severity", {}).get("text", "")
|
| 280 |
+
})
|
| 281 |
+
|
| 282 |
+
# Transform encounters
|
| 283 |
+
for encounter in encounters.get("entry", []):
|
| 284 |
+
resource = encounter.get("resource", {})
|
| 285 |
+
if resource.get("type"):
|
| 286 |
+
patient_info["encounters"].append({
|
| 287 |
+
"type": resource["type"][0].get("text", ""),
|
| 288 |
+
"status": resource.get("status", ""),
|
| 289 |
+
"start_date": resource.get("period", {}).get("start", ""),
|
| 290 |
+
"end_date": resource.get("period", {}).get("end", ""),
|
| 291 |
+
"service_provider": resource.get("serviceProvider", {}).get("display", "")
|
| 292 |
+
})
|
| 293 |
+
|
| 294 |
+
return patient_info
|
| 295 |
+
|
| 296 |
+
except Exception as e:
|
| 297 |
+
raise HTTPException(status_code=500, detail=f"Error fetching FHIR patient details: {str(e)}")
|
| 298 |
+
|
| 299 |
+
@router.post("/fhir/import-patient/{patient_id}")
|
| 300 |
+
async def import_fhir_patient(
|
| 301 |
+
patient_id: str,
|
| 302 |
+
current_user: User = Depends(get_current_user)
|
| 303 |
+
):
|
| 304 |
+
"""Import a patient from HAPI FHIR Test Server to our database"""
|
| 305 |
+
try:
|
| 306 |
+
from db.mongo import patients_collection
|
| 307 |
+
from bson import ObjectId
|
| 308 |
+
|
| 309 |
+
# Get patient data from FHIR server
|
| 310 |
+
patient_data = await fhir_integration.get_patient_by_id(patient_id)
|
| 311 |
+
|
| 312 |
+
# Transform FHIR data to our format
|
| 313 |
+
transformed_patient = {
|
| 314 |
+
"fhir_id": patient_data.get("id"),
|
| 315 |
+
"full_name": "",
|
| 316 |
+
"gender": patient_data.get("gender", "unknown"),
|
| 317 |
+
"date_of_birth": patient_data.get("birthDate", ""),
|
| 318 |
+
"address": "",
|
| 319 |
+
"phone": "",
|
| 320 |
+
"email": "",
|
| 321 |
+
"source": "fhir_import",
|
| 322 |
+
"status": "active",
|
| 323 |
+
"assigned_doctor_id": str(current_user.id),
|
| 324 |
+
"created_at": datetime.now(),
|
| 325 |
+
"updated_at": datetime.now()
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
# Extract name
|
| 329 |
+
if patient_data.get("name"):
|
| 330 |
+
name_parts = []
|
| 331 |
+
for name in patient_data["name"]:
|
| 332 |
+
if name.get("given"):
|
| 333 |
+
name_parts.extend(name["given"])
|
| 334 |
+
if name.get("family"):
|
| 335 |
+
name_parts.append(name["family"])
|
| 336 |
+
transformed_patient["full_name"] = " ".join(name_parts)
|
| 337 |
+
|
| 338 |
+
# Extract address
|
| 339 |
+
if patient_data.get("address"):
|
| 340 |
+
address_parts = []
|
| 341 |
+
for address in patient_data["address"]:
|
| 342 |
+
if address.get("line"):
|
| 343 |
+
address_parts.extend(address["line"])
|
| 344 |
+
if address.get("city"):
|
| 345 |
+
address_parts.append(address["city"])
|
| 346 |
+
if address.get("state"):
|
| 347 |
+
address_parts.append(address["state"])
|
| 348 |
+
if address.get("postalCode"):
|
| 349 |
+
address_parts.append(address["postalCode"])
|
| 350 |
+
transformed_patient["address"] = ", ".join(address_parts)
|
| 351 |
+
|
| 352 |
+
# Extract contact information
|
| 353 |
+
if patient_data.get("telecom"):
|
| 354 |
+
for telecom in patient_data["telecom"]:
|
| 355 |
+
if telecom.get("system") == "phone":
|
| 356 |
+
transformed_patient["phone"] = telecom.get("value", "")
|
| 357 |
+
elif telecom.get("system") == "email":
|
| 358 |
+
transformed_patient["email"] = telecom.get("value", "")
|
| 359 |
+
|
| 360 |
+
# Check if patient already exists
|
| 361 |
+
existing_patient = await patients_collection.find_one({"fhir_id": patient_data.get("id")})
|
| 362 |
+
if existing_patient:
|
| 363 |
+
raise HTTPException(status_code=400, detail="Patient already exists in database")
|
| 364 |
+
|
| 365 |
+
# Insert patient into database
|
| 366 |
+
result = await patients_collection.insert_one(transformed_patient)
|
| 367 |
+
|
| 368 |
+
return {
|
| 369 |
+
"message": "Patient imported successfully",
|
| 370 |
+
"patient_id": str(result.inserted_id),
|
| 371 |
+
"fhir_id": patient_data.get("id")
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
except Exception as e:
|
| 375 |
+
raise HTTPException(status_code=500, detail=f"Error importing FHIR patient: {str(e)}")
|
routes/messaging.py
ADDED
|
@@ -0,0 +1,903 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, status, Query, WebSocket, WebSocketDisconnect, UploadFile, File
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 6 |
+
import json
|
| 7 |
+
import asyncio
|
| 8 |
+
from collections import defaultdict
|
| 9 |
+
import os
|
| 10 |
+
import uuid
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from core.security import get_current_user
|
| 14 |
+
from db.mongo import db
|
| 15 |
+
from models.schemas import (
|
| 16 |
+
MessageCreate, MessageUpdate, MessageResponse, MessageListResponse,
|
| 17 |
+
ConversationResponse, ConversationListResponse, MessageType, MessageStatus,
|
| 18 |
+
NotificationCreate, NotificationResponse, NotificationListResponse,
|
| 19 |
+
NotificationType, NotificationPriority
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
router = APIRouter(prefix="/messaging", tags=["messaging"])
|
| 23 |
+
|
| 24 |
+
# WebSocket connection manager
|
| 25 |
+
class ConnectionManager:
|
| 26 |
+
def __init__(self):
|
| 27 |
+
self.active_connections: dict = defaultdict(list) # user_id -> list of connections
|
| 28 |
+
|
| 29 |
+
async def connect(self, websocket: WebSocket, user_id: str):
|
| 30 |
+
await websocket.accept()
|
| 31 |
+
self.active_connections[user_id].append(websocket)
|
| 32 |
+
|
| 33 |
+
def disconnect(self, websocket: WebSocket, user_id: str):
|
| 34 |
+
if user_id in self.active_connections:
|
| 35 |
+
self.active_connections[user_id] = [
|
| 36 |
+
conn for conn in self.active_connections[user_id] if conn != websocket
|
| 37 |
+
]
|
| 38 |
+
|
| 39 |
+
async def send_personal_message(self, message: dict, user_id: str):
|
| 40 |
+
if user_id in self.active_connections:
|
| 41 |
+
for connection in self.active_connections[user_id]:
|
| 42 |
+
try:
|
| 43 |
+
await connection.send_text(json.dumps(message))
|
| 44 |
+
except:
|
| 45 |
+
# Remove dead connections
|
| 46 |
+
self.active_connections[user_id].remove(connection)
|
| 47 |
+
|
| 48 |
+
manager = ConnectionManager()
|
| 49 |
+
|
| 50 |
+
# --- HELPER FUNCTIONS ---
|
| 51 |
+
def is_valid_object_id(id_str: str) -> bool:
|
| 52 |
+
try:
|
| 53 |
+
ObjectId(id_str)
|
| 54 |
+
return True
|
| 55 |
+
except:
|
| 56 |
+
return False
|
| 57 |
+
|
| 58 |
+
def get_conversation_id(user1_id, user2_id) -> str:
|
| 59 |
+
"""Generate a consistent conversation ID for two users"""
|
| 60 |
+
# Convert both IDs to strings for consistent comparison
|
| 61 |
+
user1_str = str(user1_id)
|
| 62 |
+
user2_str = str(user2_id)
|
| 63 |
+
# Sort IDs to ensure consistent conversation ID regardless of sender/recipient
|
| 64 |
+
sorted_ids = sorted([user1_str, user2_str])
|
| 65 |
+
return f"{sorted_ids[0]}_{sorted_ids[1]}"
|
| 66 |
+
|
| 67 |
+
async def create_notification(
|
| 68 |
+
db_client: AsyncIOMotorClient,
|
| 69 |
+
recipient_id: str,
|
| 70 |
+
title: str,
|
| 71 |
+
message: str,
|
| 72 |
+
notification_type: NotificationType,
|
| 73 |
+
priority: NotificationPriority = NotificationPriority.MEDIUM,
|
| 74 |
+
data: Optional[dict] = None
|
| 75 |
+
):
|
| 76 |
+
"""Create a notification for a user"""
|
| 77 |
+
notification_doc = {
|
| 78 |
+
"recipient_id": ObjectId(recipient_id),
|
| 79 |
+
"title": title,
|
| 80 |
+
"message": message,
|
| 81 |
+
"notification_type": notification_type,
|
| 82 |
+
"priority": priority,
|
| 83 |
+
"data": data or {},
|
| 84 |
+
"is_read": False,
|
| 85 |
+
"created_at": datetime.now()
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
result = await db_client.notifications.insert_one(notification_doc)
|
| 89 |
+
notification_doc["_id"] = result.inserted_id
|
| 90 |
+
|
| 91 |
+
# Convert ObjectId to string for WebSocket transmission
|
| 92 |
+
notification_for_ws = {
|
| 93 |
+
"id": str(notification_doc["_id"]),
|
| 94 |
+
"recipient_id": str(notification_doc["recipient_id"]),
|
| 95 |
+
"title": notification_doc["title"],
|
| 96 |
+
"message": notification_doc["message"],
|
| 97 |
+
"notification_type": notification_doc["notification_type"],
|
| 98 |
+
"priority": notification_doc["priority"],
|
| 99 |
+
"data": notification_doc["data"],
|
| 100 |
+
"is_read": notification_doc["is_read"],
|
| 101 |
+
"created_at": notification_doc["created_at"]
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
# Send real-time notification via WebSocket
|
| 105 |
+
await manager.send_personal_message({
|
| 106 |
+
"type": "new_notification",
|
| 107 |
+
"data": notification_for_ws
|
| 108 |
+
}, recipient_id)
|
| 109 |
+
|
| 110 |
+
return notification_doc
|
| 111 |
+
|
| 112 |
+
# --- WEBSOCKET ENDPOINT ---
|
| 113 |
+
@router.websocket("/ws/{user_id}")
|
| 114 |
+
async def websocket_endpoint(websocket: WebSocket, user_id: str):
|
| 115 |
+
await manager.connect(websocket, user_id)
|
| 116 |
+
print(f"🔌 WebSocket connected for user: {user_id}")
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
while True:
|
| 120 |
+
# Wait for messages from client (keep connection alive)
|
| 121 |
+
data = await websocket.receive_text()
|
| 122 |
+
try:
|
| 123 |
+
message_data = json.loads(data)
|
| 124 |
+
if message_data.get("type") == "ping":
|
| 125 |
+
# Send pong to keep connection alive
|
| 126 |
+
await websocket.send_text(json.dumps({"type": "pong"}))
|
| 127 |
+
except json.JSONDecodeError:
|
| 128 |
+
pass # Ignore invalid JSON
|
| 129 |
+
except WebSocketDisconnect:
|
| 130 |
+
print(f"🔌 WebSocket disconnected for user: {user_id}")
|
| 131 |
+
manager.disconnect(websocket, user_id)
|
| 132 |
+
except Exception as e:
|
| 133 |
+
print(f"❌ WebSocket error for user {user_id}: {e}")
|
| 134 |
+
manager.disconnect(websocket, user_id)
|
| 135 |
+
|
| 136 |
+
# --- CONVERSATION ENDPOINTS ---
|
| 137 |
+
@router.get("/conversations", response_model=ConversationListResponse)
|
| 138 |
+
async def get_conversations(
|
| 139 |
+
page: int = Query(1, ge=1),
|
| 140 |
+
limit: int = Query(20, ge=1, le=100),
|
| 141 |
+
current_user: dict = Depends(get_current_user),
|
| 142 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 143 |
+
):
|
| 144 |
+
"""Get user's conversations"""
|
| 145 |
+
skip = (page - 1) * limit
|
| 146 |
+
user_id = current_user["_id"]
|
| 147 |
+
|
| 148 |
+
# Get all messages where user is sender or recipient
|
| 149 |
+
pipeline = [
|
| 150 |
+
{
|
| 151 |
+
"$match": {
|
| 152 |
+
"$or": [
|
| 153 |
+
{"sender_id": ObjectId(user_id)},
|
| 154 |
+
{"recipient_id": ObjectId(user_id)}
|
| 155 |
+
]
|
| 156 |
+
}
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
"$sort": {"created_at": -1}
|
| 160 |
+
},
|
| 161 |
+
{
|
| 162 |
+
"$group": {
|
| 163 |
+
"_id": {
|
| 164 |
+
"$cond": [
|
| 165 |
+
{"$eq": ["$sender_id", ObjectId(user_id)]},
|
| 166 |
+
"$recipient_id",
|
| 167 |
+
"$sender_id"
|
| 168 |
+
]
|
| 169 |
+
},
|
| 170 |
+
"last_message": {"$first": "$$ROOT"},
|
| 171 |
+
"unread_count": {
|
| 172 |
+
"$sum": {
|
| 173 |
+
"$cond": [
|
| 174 |
+
{
|
| 175 |
+
"$and": [
|
| 176 |
+
{"$eq": ["$recipient_id", ObjectId(user_id)]},
|
| 177 |
+
{"$ne": ["$status", "read"]}
|
| 178 |
+
]
|
| 179 |
+
},
|
| 180 |
+
1,
|
| 181 |
+
0
|
| 182 |
+
]
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"$sort": {"last_message.created_at": -1}
|
| 189 |
+
},
|
| 190 |
+
{
|
| 191 |
+
"$skip": skip
|
| 192 |
+
},
|
| 193 |
+
{
|
| 194 |
+
"$limit": limit
|
| 195 |
+
}
|
| 196 |
+
]
|
| 197 |
+
|
| 198 |
+
conversations_data = await db_client.messages.aggregate(pipeline).to_list(length=limit)
|
| 199 |
+
|
| 200 |
+
# Get user details for each conversation
|
| 201 |
+
conversations = []
|
| 202 |
+
for conv_data in conversations_data:
|
| 203 |
+
other_user_id = str(conv_data["_id"])
|
| 204 |
+
other_user = await db_client.users.find_one({"_id": conv_data["_id"]})
|
| 205 |
+
|
| 206 |
+
if other_user:
|
| 207 |
+
# Convert user_id to string for consistent comparison
|
| 208 |
+
user_id_str = str(user_id)
|
| 209 |
+
conversation_id = get_conversation_id(user_id_str, other_user_id)
|
| 210 |
+
|
| 211 |
+
# Build last message response
|
| 212 |
+
last_message = None
|
| 213 |
+
if conv_data["last_message"]:
|
| 214 |
+
last_message = MessageResponse(
|
| 215 |
+
id=str(conv_data["last_message"]["_id"]),
|
| 216 |
+
sender_id=str(conv_data["last_message"]["sender_id"]),
|
| 217 |
+
recipient_id=str(conv_data["last_message"]["recipient_id"]),
|
| 218 |
+
sender_name=current_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else other_user["full_name"],
|
| 219 |
+
recipient_name=other_user["full_name"] if conv_data["last_message"]["sender_id"] == ObjectId(user_id) else current_user["full_name"],
|
| 220 |
+
content=conv_data["last_message"]["content"],
|
| 221 |
+
message_type=conv_data["last_message"]["message_type"],
|
| 222 |
+
attachment_url=conv_data["last_message"].get("attachment_url"),
|
| 223 |
+
reply_to_message_id=str(conv_data["last_message"]["reply_to_message_id"]) if conv_data["last_message"].get("reply_to_message_id") else None,
|
| 224 |
+
status=conv_data["last_message"]["status"],
|
| 225 |
+
is_archived=conv_data["last_message"].get("is_archived", False),
|
| 226 |
+
created_at=conv_data["last_message"]["created_at"],
|
| 227 |
+
updated_at=conv_data["last_message"]["updated_at"],
|
| 228 |
+
read_at=conv_data["last_message"].get("read_at")
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
conversations.append(ConversationResponse(
|
| 232 |
+
id=conversation_id,
|
| 233 |
+
participant_ids=[user_id_str, other_user_id],
|
| 234 |
+
participant_names=[current_user["full_name"], other_user["full_name"]],
|
| 235 |
+
last_message=last_message,
|
| 236 |
+
unread_count=conv_data["unread_count"],
|
| 237 |
+
created_at=conv_data["last_message"]["created_at"] if conv_data["last_message"] else datetime.now(),
|
| 238 |
+
updated_at=conv_data["last_message"]["updated_at"] if conv_data["last_message"] else datetime.now()
|
| 239 |
+
))
|
| 240 |
+
|
| 241 |
+
# Get total count
|
| 242 |
+
total_pipeline = [
|
| 243 |
+
{
|
| 244 |
+
"$match": {
|
| 245 |
+
"$or": [
|
| 246 |
+
{"sender_id": ObjectId(user_id)},
|
| 247 |
+
{"recipient_id": ObjectId(user_id)}
|
| 248 |
+
]
|
| 249 |
+
}
|
| 250 |
+
},
|
| 251 |
+
{
|
| 252 |
+
"$group": {
|
| 253 |
+
"_id": {
|
| 254 |
+
"$cond": [
|
| 255 |
+
{"$eq": ["$sender_id", ObjectId(user_id)]},
|
| 256 |
+
"$recipient_id",
|
| 257 |
+
"$sender_id"
|
| 258 |
+
]
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
},
|
| 262 |
+
{
|
| 263 |
+
"$count": "total"
|
| 264 |
+
}
|
| 265 |
+
]
|
| 266 |
+
|
| 267 |
+
total_result = await db_client.messages.aggregate(total_pipeline).to_list(length=1)
|
| 268 |
+
total = total_result[0]["total"] if total_result else 0
|
| 269 |
+
|
| 270 |
+
return ConversationListResponse(
|
| 271 |
+
conversations=conversations,
|
| 272 |
+
total=total,
|
| 273 |
+
page=page,
|
| 274 |
+
limit=limit
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
# --- MESSAGE ENDPOINTS ---
|
| 278 |
+
@router.post("/messages", response_model=MessageResponse, status_code=status.HTTP_201_CREATED)
|
| 279 |
+
async def send_message(
|
| 280 |
+
message_data: MessageCreate,
|
| 281 |
+
current_user: dict = Depends(get_current_user),
|
| 282 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 283 |
+
):
|
| 284 |
+
"""Send a message to another user"""
|
| 285 |
+
if not is_valid_object_id(message_data.recipient_id):
|
| 286 |
+
raise HTTPException(
|
| 287 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 288 |
+
detail="Invalid recipient ID"
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
# Check if recipient exists
|
| 292 |
+
recipient = await db_client.users.find_one({"_id": ObjectId(message_data.recipient_id)})
|
| 293 |
+
if not recipient:
|
| 294 |
+
raise HTTPException(
|
| 295 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 296 |
+
detail="Recipient not found"
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
# Check if user can message this recipient
|
| 300 |
+
# Patients can only message their doctors, doctors can message their patients
|
| 301 |
+
current_user_roles = current_user.get('roles', [])
|
| 302 |
+
if isinstance(current_user.get('role'), str):
|
| 303 |
+
current_user_roles.append(current_user.get('role'))
|
| 304 |
+
|
| 305 |
+
recipient_roles = recipient.get('roles', [])
|
| 306 |
+
if isinstance(recipient.get('role'), str):
|
| 307 |
+
recipient_roles.append(recipient.get('role'))
|
| 308 |
+
|
| 309 |
+
if 'patient' in current_user_roles:
|
| 310 |
+
# Patients can only message doctors
|
| 311 |
+
if 'doctor' not in recipient_roles:
|
| 312 |
+
raise HTTPException(
|
| 313 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 314 |
+
detail="Patients can only message doctors"
|
| 315 |
+
)
|
| 316 |
+
elif 'doctor' in current_user_roles:
|
| 317 |
+
# Doctors can only message their patients
|
| 318 |
+
if 'patient' not in recipient_roles:
|
| 319 |
+
raise HTTPException(
|
| 320 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 321 |
+
detail="Doctors can only message patients"
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
# Check reply message if provided
|
| 325 |
+
if message_data.reply_to_message_id:
|
| 326 |
+
if not is_valid_object_id(message_data.reply_to_message_id):
|
| 327 |
+
raise HTTPException(
|
| 328 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 329 |
+
detail="Invalid reply message ID"
|
| 330 |
+
)
|
| 331 |
+
|
| 332 |
+
reply_message = await db_client.messages.find_one({"_id": ObjectId(message_data.reply_to_message_id)})
|
| 333 |
+
if not reply_message:
|
| 334 |
+
raise HTTPException(
|
| 335 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 336 |
+
detail="Reply message not found"
|
| 337 |
+
)
|
| 338 |
+
|
| 339 |
+
# Create message
|
| 340 |
+
message_doc = {
|
| 341 |
+
"sender_id": ObjectId(current_user["_id"]),
|
| 342 |
+
"recipient_id": ObjectId(message_data.recipient_id),
|
| 343 |
+
"content": message_data.content,
|
| 344 |
+
"message_type": message_data.message_type,
|
| 345 |
+
"attachment_url": message_data.attachment_url,
|
| 346 |
+
"reply_to_message_id": ObjectId(message_data.reply_to_message_id) if message_data.reply_to_message_id else None,
|
| 347 |
+
"status": MessageStatus.SENT,
|
| 348 |
+
"is_archived": False,
|
| 349 |
+
"created_at": datetime.now(),
|
| 350 |
+
"updated_at": datetime.now()
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
result = await db_client.messages.insert_one(message_doc)
|
| 354 |
+
message_doc["_id"] = result.inserted_id
|
| 355 |
+
|
| 356 |
+
# Send real-time message via WebSocket
|
| 357 |
+
await manager.send_personal_message({
|
| 358 |
+
"type": "new_message",
|
| 359 |
+
"data": {
|
| 360 |
+
"id": str(message_doc["_id"]),
|
| 361 |
+
"sender_id": str(message_doc["sender_id"]),
|
| 362 |
+
"recipient_id": str(message_doc["recipient_id"]),
|
| 363 |
+
"sender_name": current_user["full_name"],
|
| 364 |
+
"recipient_name": recipient["full_name"],
|
| 365 |
+
"content": message_doc["content"],
|
| 366 |
+
"message_type": message_doc["message_type"],
|
| 367 |
+
"attachment_url": message_doc["attachment_url"],
|
| 368 |
+
"reply_to_message_id": str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
|
| 369 |
+
"status": message_doc["status"],
|
| 370 |
+
"is_archived": message_doc["is_archived"],
|
| 371 |
+
"created_at": message_doc["created_at"],
|
| 372 |
+
"updated_at": message_doc["updated_at"]
|
| 373 |
+
}
|
| 374 |
+
}, message_data.recipient_id)
|
| 375 |
+
|
| 376 |
+
# Create notification for recipient
|
| 377 |
+
await create_notification(
|
| 378 |
+
db_client,
|
| 379 |
+
message_data.recipient_id,
|
| 380 |
+
f"New message from {current_user['full_name']}",
|
| 381 |
+
message_data.content[:100] + "..." if len(message_data.content) > 100 else message_data.content,
|
| 382 |
+
NotificationType.MESSAGE,
|
| 383 |
+
NotificationPriority.MEDIUM,
|
| 384 |
+
{"message_id": str(message_doc["_id"]), "sender_id": str(current_user["_id"])}
|
| 385 |
+
)
|
| 386 |
+
|
| 387 |
+
return MessageResponse(
|
| 388 |
+
id=str(message_doc["_id"]),
|
| 389 |
+
sender_id=str(message_doc["sender_id"]),
|
| 390 |
+
recipient_id=str(message_doc["recipient_id"]),
|
| 391 |
+
sender_name=current_user["full_name"],
|
| 392 |
+
recipient_name=recipient["full_name"],
|
| 393 |
+
content=message_doc["content"],
|
| 394 |
+
message_type=message_doc["message_type"],
|
| 395 |
+
attachment_url=message_doc["attachment_url"],
|
| 396 |
+
reply_to_message_id=str(message_doc["reply_to_message_id"]) if message_doc["reply_to_message_id"] else None,
|
| 397 |
+
status=message_doc["status"],
|
| 398 |
+
is_archived=message_doc["is_archived"],
|
| 399 |
+
created_at=message_doc["created_at"],
|
| 400 |
+
updated_at=message_doc["updated_at"]
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
@router.get("/messages/{conversation_id}", response_model=MessageListResponse)
|
| 404 |
+
async def get_messages(
|
| 405 |
+
conversation_id: str,
|
| 406 |
+
page: int = Query(1, ge=1),
|
| 407 |
+
limit: int = Query(50, ge=1, le=100),
|
| 408 |
+
current_user: dict = Depends(get_current_user),
|
| 409 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 410 |
+
):
|
| 411 |
+
"""Get messages for a specific conversation"""
|
| 412 |
+
skip = (page - 1) * limit
|
| 413 |
+
user_id = current_user["_id"]
|
| 414 |
+
|
| 415 |
+
# Parse conversation ID to get the other participant
|
| 416 |
+
try:
|
| 417 |
+
participant_ids = conversation_id.split("_")
|
| 418 |
+
if len(participant_ids) != 2:
|
| 419 |
+
raise HTTPException(
|
| 420 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 421 |
+
detail="Invalid conversation ID format"
|
| 422 |
+
)
|
| 423 |
+
|
| 424 |
+
# Find the other participant
|
| 425 |
+
other_user_id = None
|
| 426 |
+
user_id_str = str(user_id)
|
| 427 |
+
for pid in participant_ids:
|
| 428 |
+
if pid != user_id_str:
|
| 429 |
+
other_user_id = pid
|
| 430 |
+
break
|
| 431 |
+
|
| 432 |
+
if not other_user_id or not is_valid_object_id(other_user_id):
|
| 433 |
+
raise HTTPException(
|
| 434 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 435 |
+
detail="Invalid conversation ID"
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
# Verify the other user exists
|
| 439 |
+
other_user = await db_client.users.find_one({"_id": ObjectId(other_user_id)})
|
| 440 |
+
if not other_user:
|
| 441 |
+
raise HTTPException(
|
| 442 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 443 |
+
detail="Conversation participant not found"
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
except Exception as e:
|
| 447 |
+
raise HTTPException(
|
| 448 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 449 |
+
detail="Invalid conversation ID"
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
# Get messages between the two users
|
| 453 |
+
filter_query = {
|
| 454 |
+
"$or": [
|
| 455 |
+
{
|
| 456 |
+
"sender_id": ObjectId(user_id),
|
| 457 |
+
"recipient_id": ObjectId(other_user_id)
|
| 458 |
+
},
|
| 459 |
+
{
|
| 460 |
+
"sender_id": ObjectId(other_user_id),
|
| 461 |
+
"recipient_id": ObjectId(user_id)
|
| 462 |
+
}
|
| 463 |
+
]
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
# Get messages
|
| 467 |
+
cursor = db_client.messages.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
|
| 468 |
+
messages = await cursor.to_list(length=limit)
|
| 469 |
+
|
| 470 |
+
# Mark messages as read
|
| 471 |
+
unread_messages = [
|
| 472 |
+
msg["_id"] for msg in messages
|
| 473 |
+
if msg["recipient_id"] == ObjectId(user_id) and msg["status"] != "read"
|
| 474 |
+
]
|
| 475 |
+
|
| 476 |
+
if unread_messages:
|
| 477 |
+
await db_client.messages.update_many(
|
| 478 |
+
{"_id": {"$in": unread_messages}},
|
| 479 |
+
{"$set": {"status": "read", "read_at": datetime.now()}}
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
# Get total count
|
| 483 |
+
total = await db_client.messages.count_documents(filter_query)
|
| 484 |
+
|
| 485 |
+
# Build message responses
|
| 486 |
+
message_responses = []
|
| 487 |
+
for msg in messages:
|
| 488 |
+
sender = await db_client.users.find_one({"_id": msg["sender_id"]})
|
| 489 |
+
recipient = await db_client.users.find_one({"_id": msg["recipient_id"]})
|
| 490 |
+
|
| 491 |
+
message_responses.append(MessageResponse(
|
| 492 |
+
id=str(msg["_id"]),
|
| 493 |
+
sender_id=str(msg["sender_id"]),
|
| 494 |
+
recipient_id=str(msg["recipient_id"]),
|
| 495 |
+
sender_name=sender["full_name"] if sender else "Unknown User",
|
| 496 |
+
recipient_name=recipient["full_name"] if recipient else "Unknown User",
|
| 497 |
+
content=msg["content"],
|
| 498 |
+
message_type=msg["message_type"],
|
| 499 |
+
attachment_url=msg.get("attachment_url"),
|
| 500 |
+
reply_to_message_id=str(msg["reply_to_message_id"]) if msg.get("reply_to_message_id") else None,
|
| 501 |
+
status=msg["status"],
|
| 502 |
+
is_archived=msg.get("is_archived", False),
|
| 503 |
+
created_at=msg["created_at"],
|
| 504 |
+
updated_at=msg["updated_at"],
|
| 505 |
+
read_at=msg.get("read_at")
|
| 506 |
+
))
|
| 507 |
+
|
| 508 |
+
return MessageListResponse(
|
| 509 |
+
messages=message_responses,
|
| 510 |
+
total=total,
|
| 511 |
+
page=page,
|
| 512 |
+
limit=limit,
|
| 513 |
+
conversation_id=conversation_id
|
| 514 |
+
)
|
| 515 |
+
|
| 516 |
+
@router.put("/messages/{message_id}", response_model=MessageResponse)
|
| 517 |
+
async def update_message(
|
| 518 |
+
message_id: str,
|
| 519 |
+
message_data: MessageUpdate,
|
| 520 |
+
current_user: dict = Depends(get_current_user),
|
| 521 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 522 |
+
):
|
| 523 |
+
"""Update a message (only sender can update)"""
|
| 524 |
+
if not is_valid_object_id(message_id):
|
| 525 |
+
raise HTTPException(
|
| 526 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 527 |
+
detail="Invalid message ID"
|
| 528 |
+
)
|
| 529 |
+
|
| 530 |
+
message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 531 |
+
if not message:
|
| 532 |
+
raise HTTPException(
|
| 533 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 534 |
+
detail="Message not found"
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
# Only sender can update message
|
| 538 |
+
if message["sender_id"] != ObjectId(current_user["_id"]):
|
| 539 |
+
raise HTTPException(
|
| 540 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 541 |
+
detail="You can only update your own messages"
|
| 542 |
+
)
|
| 543 |
+
|
| 544 |
+
# Build update data
|
| 545 |
+
update_data = {"updated_at": datetime.now()}
|
| 546 |
+
|
| 547 |
+
if message_data.content is not None:
|
| 548 |
+
update_data["content"] = message_data.content
|
| 549 |
+
if message_data.is_archived is not None:
|
| 550 |
+
update_data["is_archived"] = message_data.is_archived
|
| 551 |
+
|
| 552 |
+
# Update message
|
| 553 |
+
await db_client.messages.update_one(
|
| 554 |
+
{"_id": ObjectId(message_id)},
|
| 555 |
+
{"$set": update_data}
|
| 556 |
+
)
|
| 557 |
+
|
| 558 |
+
# Get updated message
|
| 559 |
+
updated_message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 560 |
+
|
| 561 |
+
# Get sender and recipient names
|
| 562 |
+
sender = await db_client.users.find_one({"_id": updated_message["sender_id"]})
|
| 563 |
+
recipient = await db_client.users.find_one({"_id": updated_message["recipient_id"]})
|
| 564 |
+
|
| 565 |
+
return MessageResponse(
|
| 566 |
+
id=str(updated_message["_id"]),
|
| 567 |
+
sender_id=str(updated_message["sender_id"]),
|
| 568 |
+
recipient_id=str(updated_message["recipient_id"]),
|
| 569 |
+
sender_name=sender["full_name"] if sender else "Unknown User",
|
| 570 |
+
recipient_name=recipient["full_name"] if recipient else "Unknown User",
|
| 571 |
+
content=updated_message["content"],
|
| 572 |
+
message_type=updated_message["message_type"],
|
| 573 |
+
attachment_url=updated_message.get("attachment_url"),
|
| 574 |
+
reply_to_message_id=str(updated_message["reply_to_message_id"]) if updated_message.get("reply_to_message_id") else None,
|
| 575 |
+
status=updated_message["status"],
|
| 576 |
+
is_archived=updated_message.get("is_archived", False),
|
| 577 |
+
created_at=updated_message["created_at"],
|
| 578 |
+
updated_at=updated_message["updated_at"],
|
| 579 |
+
read_at=updated_message.get("read_at")
|
| 580 |
+
)
|
| 581 |
+
|
| 582 |
+
@router.delete("/messages/{message_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 583 |
+
async def delete_message(
|
| 584 |
+
message_id: str,
|
| 585 |
+
current_user: dict = Depends(get_current_user),
|
| 586 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 587 |
+
):
|
| 588 |
+
"""Delete a message (only sender can delete)"""
|
| 589 |
+
if not is_valid_object_id(message_id):
|
| 590 |
+
raise HTTPException(
|
| 591 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 592 |
+
detail="Invalid message ID"
|
| 593 |
+
)
|
| 594 |
+
|
| 595 |
+
message = await db_client.messages.find_one({"_id": ObjectId(message_id)})
|
| 596 |
+
if not message:
|
| 597 |
+
raise HTTPException(
|
| 598 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 599 |
+
detail="Message not found"
|
| 600 |
+
)
|
| 601 |
+
|
| 602 |
+
# Only sender can delete message
|
| 603 |
+
if message["sender_id"] != ObjectId(current_user["_id"]):
|
| 604 |
+
raise HTTPException(
|
| 605 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 606 |
+
detail="You can only delete your own messages"
|
| 607 |
+
)
|
| 608 |
+
|
| 609 |
+
await db_client.messages.delete_one({"_id": ObjectId(message_id)})
|
| 610 |
+
|
| 611 |
+
# --- NOTIFICATION ENDPOINTS ---
|
| 612 |
+
@router.get("/notifications", response_model=NotificationListResponse)
|
| 613 |
+
async def get_notifications(
|
| 614 |
+
page: int = Query(1, ge=1),
|
| 615 |
+
limit: int = Query(20, ge=1, le=100),
|
| 616 |
+
unread_only: bool = Query(False),
|
| 617 |
+
current_user: dict = Depends(get_current_user),
|
| 618 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 619 |
+
):
|
| 620 |
+
"""Get user's notifications"""
|
| 621 |
+
skip = (page - 1) * limit
|
| 622 |
+
user_id = current_user["_id"]
|
| 623 |
+
|
| 624 |
+
# Build filter
|
| 625 |
+
filter_query = {"recipient_id": ObjectId(user_id)}
|
| 626 |
+
if unread_only:
|
| 627 |
+
filter_query["is_read"] = False
|
| 628 |
+
|
| 629 |
+
# Get notifications
|
| 630 |
+
cursor = db_client.notifications.find(filter_query).sort("created_at", -1).skip(skip).limit(limit)
|
| 631 |
+
notifications = await cursor.to_list(length=limit)
|
| 632 |
+
|
| 633 |
+
# Get total count and unread count
|
| 634 |
+
total = await db_client.notifications.count_documents(filter_query)
|
| 635 |
+
unread_count = await db_client.notifications.count_documents({
|
| 636 |
+
"recipient_id": ObjectId(user_id),
|
| 637 |
+
"is_read": False
|
| 638 |
+
})
|
| 639 |
+
|
| 640 |
+
# Build notification responses
|
| 641 |
+
notification_responses = []
|
| 642 |
+
for notif in notifications:
|
| 643 |
+
# Convert any ObjectId fields in data to strings
|
| 644 |
+
data = notif.get("data", {})
|
| 645 |
+
if data:
|
| 646 |
+
# Convert ObjectId fields to strings
|
| 647 |
+
for key, value in data.items():
|
| 648 |
+
if isinstance(value, ObjectId):
|
| 649 |
+
data[key] = str(value)
|
| 650 |
+
|
| 651 |
+
notification_responses.append(NotificationResponse(
|
| 652 |
+
id=str(notif["_id"]),
|
| 653 |
+
recipient_id=str(notif["recipient_id"]),
|
| 654 |
+
recipient_name=current_user["full_name"],
|
| 655 |
+
title=notif["title"],
|
| 656 |
+
message=notif["message"],
|
| 657 |
+
notification_type=notif["notification_type"],
|
| 658 |
+
priority=notif["priority"],
|
| 659 |
+
data=data,
|
| 660 |
+
is_read=notif.get("is_read", False),
|
| 661 |
+
created_at=notif["created_at"],
|
| 662 |
+
read_at=notif.get("read_at")
|
| 663 |
+
))
|
| 664 |
+
|
| 665 |
+
return NotificationListResponse(
|
| 666 |
+
notifications=notification_responses,
|
| 667 |
+
total=total,
|
| 668 |
+
unread_count=unread_count,
|
| 669 |
+
page=page,
|
| 670 |
+
limit=limit
|
| 671 |
+
)
|
| 672 |
+
|
| 673 |
+
@router.put("/notifications/{notification_id}/read", response_model=NotificationResponse)
|
| 674 |
+
async def mark_notification_read(
|
| 675 |
+
notification_id: str,
|
| 676 |
+
current_user: dict = Depends(get_current_user),
|
| 677 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 678 |
+
):
|
| 679 |
+
"""Mark a notification as read"""
|
| 680 |
+
if not is_valid_object_id(notification_id):
|
| 681 |
+
raise HTTPException(
|
| 682 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 683 |
+
detail="Invalid notification ID"
|
| 684 |
+
)
|
| 685 |
+
|
| 686 |
+
notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
|
| 687 |
+
if not notification:
|
| 688 |
+
raise HTTPException(
|
| 689 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 690 |
+
detail="Notification not found"
|
| 691 |
+
)
|
| 692 |
+
|
| 693 |
+
# Only recipient can mark as read
|
| 694 |
+
if notification["recipient_id"] != ObjectId(current_user["_id"]):
|
| 695 |
+
raise HTTPException(
|
| 696 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 697 |
+
detail="You can only mark your own notifications as read"
|
| 698 |
+
)
|
| 699 |
+
|
| 700 |
+
# Update notification
|
| 701 |
+
await db_client.notifications.update_one(
|
| 702 |
+
{"_id": ObjectId(notification_id)},
|
| 703 |
+
{"$set": {"is_read": True, "read_at": datetime.now()}}
|
| 704 |
+
)
|
| 705 |
+
|
| 706 |
+
# Get updated notification
|
| 707 |
+
updated_notification = await db_client.notifications.find_one({"_id": ObjectId(notification_id)})
|
| 708 |
+
|
| 709 |
+
# Convert any ObjectId fields in data to strings
|
| 710 |
+
data = updated_notification.get("data", {})
|
| 711 |
+
if data:
|
| 712 |
+
# Convert ObjectId fields to strings
|
| 713 |
+
for key, value in data.items():
|
| 714 |
+
if isinstance(value, ObjectId):
|
| 715 |
+
data[key] = str(value)
|
| 716 |
+
|
| 717 |
+
return NotificationResponse(
|
| 718 |
+
id=str(updated_notification["_id"]),
|
| 719 |
+
recipient_id=str(updated_notification["recipient_id"]),
|
| 720 |
+
recipient_name=current_user["full_name"],
|
| 721 |
+
title=updated_notification["title"],
|
| 722 |
+
message=updated_notification["message"],
|
| 723 |
+
notification_type=updated_notification["notification_type"],
|
| 724 |
+
priority=updated_notification["priority"],
|
| 725 |
+
data=data,
|
| 726 |
+
is_read=updated_notification.get("is_read", False),
|
| 727 |
+
created_at=updated_notification["created_at"],
|
| 728 |
+
read_at=updated_notification.get("read_at")
|
| 729 |
+
)
|
| 730 |
+
|
| 731 |
+
@router.put("/notifications/read-all", status_code=status.HTTP_200_OK)
|
| 732 |
+
async def mark_all_notifications_read(
|
| 733 |
+
current_user: dict = Depends(get_current_user),
|
| 734 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 735 |
+
):
|
| 736 |
+
"""Mark all user's notifications as read"""
|
| 737 |
+
user_id = current_user["_id"]
|
| 738 |
+
|
| 739 |
+
await db_client.notifications.update_many(
|
| 740 |
+
{
|
| 741 |
+
"recipient_id": ObjectId(user_id),
|
| 742 |
+
"is_read": False
|
| 743 |
+
},
|
| 744 |
+
{
|
| 745 |
+
"$set": {
|
| 746 |
+
"is_read": True,
|
| 747 |
+
"read_at": datetime.now()
|
| 748 |
+
}
|
| 749 |
+
}
|
| 750 |
+
)
|
| 751 |
+
|
| 752 |
+
return {"message": "All notifications marked as read"}
|
| 753 |
+
|
| 754 |
+
# --- FILE UPLOAD ENDPOINT ---
|
| 755 |
+
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
| 756 |
+
async def upload_file(
|
| 757 |
+
file: UploadFile = File(...),
|
| 758 |
+
current_user: dict = Depends(get_current_user)
|
| 759 |
+
):
|
| 760 |
+
"""Upload a file for messaging"""
|
| 761 |
+
|
| 762 |
+
# Validate file type
|
| 763 |
+
allowed_types = {
|
| 764 |
+
'image': ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'],
|
| 765 |
+
'document': ['.pdf', '.doc', '.docx', '.txt', '.rtf'],
|
| 766 |
+
'spreadsheet': ['.xls', '.xlsx', '.csv'],
|
| 767 |
+
'presentation': ['.ppt', '.pptx'],
|
| 768 |
+
'archive': ['.zip', '.rar', '.7z']
|
| 769 |
+
}
|
| 770 |
+
|
| 771 |
+
# Get file extension
|
| 772 |
+
file_ext = Path(file.filename).suffix.lower()
|
| 773 |
+
|
| 774 |
+
# Check if file type is allowed
|
| 775 |
+
is_allowed = False
|
| 776 |
+
file_category = None
|
| 777 |
+
for category, extensions in allowed_types.items():
|
| 778 |
+
if file_ext in extensions:
|
| 779 |
+
is_allowed = True
|
| 780 |
+
file_category = category
|
| 781 |
+
break
|
| 782 |
+
|
| 783 |
+
if not is_allowed:
|
| 784 |
+
raise HTTPException(
|
| 785 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 786 |
+
detail=f"File type {file_ext} is not allowed. Allowed types: {', '.join([ext for exts in allowed_types.values() for ext in exts])}"
|
| 787 |
+
)
|
| 788 |
+
|
| 789 |
+
# Check file size (max 10MB)
|
| 790 |
+
max_size = 10 * 1024 * 1024 # 10MB
|
| 791 |
+
if file.size and file.size > max_size:
|
| 792 |
+
raise HTTPException(
|
| 793 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 794 |
+
detail=f"File size exceeds maximum limit of 10MB"
|
| 795 |
+
)
|
| 796 |
+
|
| 797 |
+
# Create uploads directory if it doesn't exist
|
| 798 |
+
try:
|
| 799 |
+
upload_dir = Path("uploads")
|
| 800 |
+
upload_dir.mkdir(exist_ok=True)
|
| 801 |
+
except PermissionError:
|
| 802 |
+
# In containerized environments, use temp directory
|
| 803 |
+
import tempfile
|
| 804 |
+
upload_dir = Path(tempfile.gettempdir()) / "uploads"
|
| 805 |
+
upload_dir.mkdir(exist_ok=True)
|
| 806 |
+
|
| 807 |
+
# Create category subdirectory
|
| 808 |
+
category_dir = upload_dir / file_category
|
| 809 |
+
category_dir.mkdir(exist_ok=True)
|
| 810 |
+
|
| 811 |
+
# Generate unique filename
|
| 812 |
+
unique_filename = f"{uuid.uuid4()}{file_ext}"
|
| 813 |
+
file_path = category_dir / unique_filename
|
| 814 |
+
|
| 815 |
+
try:
|
| 816 |
+
# Save file
|
| 817 |
+
with open(file_path, "wb") as buffer:
|
| 818 |
+
content = await file.read()
|
| 819 |
+
buffer.write(content)
|
| 820 |
+
|
| 821 |
+
# Return file info
|
| 822 |
+
return {
|
| 823 |
+
"filename": file.filename,
|
| 824 |
+
"file_url": f"/uploads/{file_category}/{unique_filename}",
|
| 825 |
+
"file_size": len(content),
|
| 826 |
+
"file_type": file_category,
|
| 827 |
+
"message_type": MessageType.IMAGE if file_category == 'image' else MessageType.FILE
|
| 828 |
+
}
|
| 829 |
+
|
| 830 |
+
except Exception as e:
|
| 831 |
+
# Clean up file if save fails
|
| 832 |
+
if file_path.exists():
|
| 833 |
+
file_path.unlink()
|
| 834 |
+
raise HTTPException(
|
| 835 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 836 |
+
detail=f"Failed to upload file: {str(e)}"
|
| 837 |
+
)
|
| 838 |
+
|
| 839 |
+
# --- STATIC FILE SERVING ---
|
| 840 |
+
@router.get("/uploads/{category}/{filename}")
|
| 841 |
+
async def serve_file(category: str, filename: str):
|
| 842 |
+
"""Serve uploaded files"""
|
| 843 |
+
import os
|
| 844 |
+
|
| 845 |
+
# Get the current working directory and construct absolute path
|
| 846 |
+
current_dir = os.getcwd()
|
| 847 |
+
file_path = Path(current_dir) / "uploads" / category / filename
|
| 848 |
+
|
| 849 |
+
print(f"🔍 Looking for file: {file_path}")
|
| 850 |
+
print(f"📁 File exists: {file_path.exists()}")
|
| 851 |
+
|
| 852 |
+
if not file_path.exists():
|
| 853 |
+
raise HTTPException(
|
| 854 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 855 |
+
detail=f"File not found: {file_path}"
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
# Determine content type based on file extension
|
| 859 |
+
ext = file_path.suffix.lower()
|
| 860 |
+
content_types = {
|
| 861 |
+
'.jpg': 'image/jpeg',
|
| 862 |
+
'.jpeg': 'image/jpeg',
|
| 863 |
+
'.png': 'image/png',
|
| 864 |
+
'.gif': 'image/gif',
|
| 865 |
+
'.bmp': 'image/bmp',
|
| 866 |
+
'.webp': 'image/webp',
|
| 867 |
+
'.pdf': 'application/pdf',
|
| 868 |
+
'.doc': 'application/msword',
|
| 869 |
+
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
| 870 |
+
'.txt': 'text/plain',
|
| 871 |
+
'.rtf': 'application/rtf',
|
| 872 |
+
'.xls': 'application/vnd.ms-excel',
|
| 873 |
+
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
| 874 |
+
'.csv': 'text/csv',
|
| 875 |
+
'.ppt': 'application/vnd.ms-powerpoint',
|
| 876 |
+
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
| 877 |
+
'.zip': 'application/zip',
|
| 878 |
+
'.rar': 'application/x-rar-compressed',
|
| 879 |
+
'.7z': 'application/x-7z-compressed'
|
| 880 |
+
}
|
| 881 |
+
|
| 882 |
+
content_type = content_types.get(ext, 'application/octet-stream')
|
| 883 |
+
|
| 884 |
+
try:
|
| 885 |
+
# Read and return file content
|
| 886 |
+
with open(file_path, "rb") as f:
|
| 887 |
+
content = f.read()
|
| 888 |
+
|
| 889 |
+
from fastapi.responses import Response
|
| 890 |
+
return Response(
|
| 891 |
+
content=content,
|
| 892 |
+
media_type=content_type,
|
| 893 |
+
headers={
|
| 894 |
+
"Content-Disposition": f"inline; filename={filename}",
|
| 895 |
+
"Cache-Control": "public, max-age=31536000"
|
| 896 |
+
}
|
| 897 |
+
)
|
| 898 |
+
except Exception as e:
|
| 899 |
+
print(f"❌ Error reading file: {e}")
|
| 900 |
+
raise HTTPException(
|
| 901 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 902 |
+
detail=f"Error reading file: {str(e)}"
|
| 903 |
+
)
|
routes/patients.py
ADDED
|
@@ -0,0 +1,1153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, Query, status, Body
|
| 2 |
+
from db.mongo import patients_collection, db # Added db import
|
| 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, PatientCreate
|
| 7 |
+
from models.schemas import PatientListResponse # Fixed import
|
| 8 |
+
from api.services.fhir_integration import HAPIFHIRIntegrationService
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
from bson import ObjectId
|
| 11 |
+
from bson.errors import InvalidId
|
| 12 |
+
from typing import Optional, List, Dict, Any
|
| 13 |
+
from pymongo import UpdateOne, DeleteOne
|
| 14 |
+
from pymongo.errors import BulkWriteError
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
import glob
|
| 18 |
+
import uuid
|
| 19 |
+
import re
|
| 20 |
+
import logging
|
| 21 |
+
import time
|
| 22 |
+
import os
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
from motor.motor_asyncio import AsyncIOMotorClient
|
| 25 |
+
|
| 26 |
+
# Configure logging
|
| 27 |
+
logging.basicConfig(
|
| 28 |
+
level=logging.INFO,
|
| 29 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 30 |
+
)
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
router = APIRouter()
|
| 34 |
+
|
| 35 |
+
# Configuration
|
| 36 |
+
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
| 37 |
+
SYNTHEA_DATA_DIR = BASE_DIR / "output" / "fhir"
|
| 38 |
+
try:
|
| 39 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 40 |
+
except PermissionError:
|
| 41 |
+
# In containerized environments, we might not have write permissions
|
| 42 |
+
# Use a temporary directory instead
|
| 43 |
+
import tempfile
|
| 44 |
+
SYNTHEA_DATA_DIR = Path(tempfile.gettempdir()) / "fhir"
|
| 45 |
+
os.makedirs(SYNTHEA_DATA_DIR, exist_ok=True)
|
| 46 |
+
|
| 47 |
+
# Pydantic models for update validation
|
| 48 |
+
class ConditionUpdate(BaseModel):
|
| 49 |
+
id: Optional[str] = None
|
| 50 |
+
code: Optional[str] = None
|
| 51 |
+
status: Optional[str] = None
|
| 52 |
+
onset_date: Optional[str] = None
|
| 53 |
+
recorded_date: Optional[str] = None
|
| 54 |
+
verification_status: Optional[str] = None
|
| 55 |
+
notes: Optional[str] = None
|
| 56 |
+
|
| 57 |
+
class MedicationUpdate(BaseModel):
|
| 58 |
+
id: Optional[str] = None
|
| 59 |
+
name: Optional[str] = None
|
| 60 |
+
status: Optional[str] = None
|
| 61 |
+
prescribed_date: Optional[str] = None
|
| 62 |
+
requester: Optional[str] = None
|
| 63 |
+
dosage: Optional[str] = None
|
| 64 |
+
|
| 65 |
+
class EncounterUpdate(BaseModel):
|
| 66 |
+
id: Optional[str] = None
|
| 67 |
+
type: Optional[str] = None
|
| 68 |
+
status: Optional[str] = None
|
| 69 |
+
period: Optional[Dict[str, str]] = None
|
| 70 |
+
service_provider: Optional[str] = None
|
| 71 |
+
|
| 72 |
+
class NoteUpdate(BaseModel):
|
| 73 |
+
id: Optional[str] = None
|
| 74 |
+
title: Optional[str] = None
|
| 75 |
+
date: Optional[str] = None
|
| 76 |
+
author: Optional[str] = None
|
| 77 |
+
content: Optional[str] = None
|
| 78 |
+
|
| 79 |
+
class PatientUpdate(BaseModel):
|
| 80 |
+
full_name: Optional[str] = None
|
| 81 |
+
gender: Optional[str] = None
|
| 82 |
+
date_of_birth: Optional[str] = None
|
| 83 |
+
address: Optional[str] = None
|
| 84 |
+
city: Optional[str] = None
|
| 85 |
+
state: Optional[str] = None
|
| 86 |
+
postal_code: Optional[str] = None
|
| 87 |
+
country: Optional[str] = None
|
| 88 |
+
marital_status: Optional[str] = None
|
| 89 |
+
language: Optional[str] = None
|
| 90 |
+
conditions: Optional[List[ConditionUpdate]] = None
|
| 91 |
+
medications: Optional[List[MedicationUpdate]] = None
|
| 92 |
+
encounters: Optional[List[EncounterUpdate]] = None
|
| 93 |
+
notes: Optional[List[NoteUpdate]] = None
|
| 94 |
+
|
| 95 |
+
@router.get("/debug/count")
|
| 96 |
+
async def debug_patient_count():
|
| 97 |
+
"""Debug endpoint to verify patient counts"""
|
| 98 |
+
try:
|
| 99 |
+
total = await patients_collection.count_documents({})
|
| 100 |
+
synthea = await patients_collection.count_documents({"source": "synthea"})
|
| 101 |
+
manual = await patients_collection.count_documents({"source": "manual"})
|
| 102 |
+
return {
|
| 103 |
+
"total": total,
|
| 104 |
+
"synthea": synthea,
|
| 105 |
+
"manual": manual,
|
| 106 |
+
"message": f"Found {total} total patients ({synthea} from synthea, {manual} manual)"
|
| 107 |
+
}
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error counting patients: {str(e)}")
|
| 110 |
+
raise HTTPException(
|
| 111 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 112 |
+
detail=f"Error counting patients: {str(e)}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
@router.post("/patients", status_code=status.HTTP_201_CREATED)
|
| 116 |
+
async def create_patient(
|
| 117 |
+
patient_data: PatientCreate,
|
| 118 |
+
current_user: dict = Depends(get_current_user)
|
| 119 |
+
):
|
| 120 |
+
"""Create a new patient in the database"""
|
| 121 |
+
logger.info(f"Creating new patient by user {current_user.get('email')}")
|
| 122 |
+
|
| 123 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 124 |
+
logger.warning(f"Unauthorized create attempt by {current_user.get('email')}")
|
| 125 |
+
raise HTTPException(
|
| 126 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 127 |
+
detail="Only administrators and doctors can create patients"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
# Prepare the patient document
|
| 132 |
+
patient_doc = patient_data.dict()
|
| 133 |
+
now = datetime.utcnow().isoformat()
|
| 134 |
+
|
| 135 |
+
# Add system-generated fields
|
| 136 |
+
patient_doc.update({
|
| 137 |
+
"fhir_id": str(uuid.uuid4()),
|
| 138 |
+
"import_date": now,
|
| 139 |
+
"last_updated": now,
|
| 140 |
+
"source": "manual",
|
| 141 |
+
"created_by": current_user.get('email')
|
| 142 |
+
})
|
| 143 |
+
|
| 144 |
+
# Ensure arrays exist even if empty
|
| 145 |
+
for field in ['conditions', 'medications', 'encounters', 'notes']:
|
| 146 |
+
if field not in patient_doc:
|
| 147 |
+
patient_doc[field] = []
|
| 148 |
+
|
| 149 |
+
# Insert the patient document
|
| 150 |
+
result = await patients_collection.insert_one(patient_doc)
|
| 151 |
+
|
| 152 |
+
# Return the created patient with the generated ID
|
| 153 |
+
created_patient = await patients_collection.find_one(
|
| 154 |
+
{"_id": result.inserted_id}
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
if not created_patient:
|
| 158 |
+
logger.error("Failed to retrieve created patient")
|
| 159 |
+
raise HTTPException(
|
| 160 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 161 |
+
detail="Failed to retrieve created patient"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
created_patient["id"] = str(created_patient["_id"])
|
| 165 |
+
del created_patient["_id"]
|
| 166 |
+
|
| 167 |
+
logger.info(f"Successfully created patient {created_patient['fhir_id']}")
|
| 168 |
+
return created_patient
|
| 169 |
+
|
| 170 |
+
except Exception as e:
|
| 171 |
+
logger.error(f"Failed to create patient: {str(e)}")
|
| 172 |
+
raise HTTPException(
|
| 173 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 174 |
+
detail=f"Failed to create patient: {str(e)}"
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
@router.delete("/patients/{patient_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 178 |
+
async def delete_patient(
|
| 179 |
+
patient_id: str,
|
| 180 |
+
current_user: dict = Depends(get_current_user)
|
| 181 |
+
):
|
| 182 |
+
"""Delete a patient from the database"""
|
| 183 |
+
logger.info(f"Deleting patient {patient_id} by user {current_user.get('email')}")
|
| 184 |
+
|
| 185 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 186 |
+
logger.warning(f"Unauthorized delete attempt by {current_user.get('email')}")
|
| 187 |
+
raise HTTPException(
|
| 188 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 189 |
+
detail="Only administrators can delete patients"
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
try:
|
| 193 |
+
# Build the query based on whether patient_id is a valid ObjectId
|
| 194 |
+
query = {"fhir_id": patient_id}
|
| 195 |
+
if ObjectId.is_valid(patient_id):
|
| 196 |
+
query = {
|
| 197 |
+
"$or": [
|
| 198 |
+
{"_id": ObjectId(patient_id)},
|
| 199 |
+
{"fhir_id": patient_id}
|
| 200 |
+
]
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# Check if patient exists
|
| 204 |
+
patient = await patients_collection.find_one(query)
|
| 205 |
+
|
| 206 |
+
if not patient:
|
| 207 |
+
logger.warning(f"Patient not found for deletion: {patient_id}")
|
| 208 |
+
raise HTTPException(
|
| 209 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 210 |
+
detail="Patient not found"
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
# Perform deletion
|
| 214 |
+
result = await patients_collection.delete_one(query)
|
| 215 |
+
|
| 216 |
+
if result.deleted_count == 0:
|
| 217 |
+
logger.error(f"Failed to delete patient {patient_id}")
|
| 218 |
+
raise HTTPException(
|
| 219 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 220 |
+
detail="Failed to delete patient"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
logger.info(f"Successfully deleted patient {patient_id}")
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
except HTTPException:
|
| 227 |
+
raise
|
| 228 |
+
except Exception as e:
|
| 229 |
+
logger.error(f"Failed to delete patient {patient_id}: {str(e)}")
|
| 230 |
+
raise HTTPException(
|
| 231 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 232 |
+
detail=f"Failed to delete patient: {str(e)}"
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
async def process_synthea_patient(bundle: dict, file_path: str) -> Optional[dict]:
|
| 236 |
+
logger.debug(f"Processing patient from file: {file_path}")
|
| 237 |
+
patient_data = {}
|
| 238 |
+
notes = []
|
| 239 |
+
conditions = []
|
| 240 |
+
medications = []
|
| 241 |
+
encounters = []
|
| 242 |
+
|
| 243 |
+
# Validate bundle structure
|
| 244 |
+
if not isinstance(bundle, dict) or 'entry' not in bundle:
|
| 245 |
+
logger.error(f"Invalid FHIR bundle structure in {file_path}")
|
| 246 |
+
return None
|
| 247 |
+
|
| 248 |
+
for entry in bundle.get('entry', []):
|
| 249 |
+
resource = entry.get('resource', {})
|
| 250 |
+
resource_type = resource.get('resourceType')
|
| 251 |
+
|
| 252 |
+
if not resource_type:
|
| 253 |
+
logger.warning(f"Skipping entry with missing resourceType in {file_path}")
|
| 254 |
+
continue
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
if resource_type == 'Patient':
|
| 258 |
+
name = resource.get('name', [{}])[0]
|
| 259 |
+
address = resource.get('address', [{}])[0]
|
| 260 |
+
|
| 261 |
+
# Construct full name and remove numbers
|
| 262 |
+
raw_full_name = f"{' '.join(name.get('given', ['']))} {name.get('family', '')}".strip()
|
| 263 |
+
clean_full_name = re.sub(r'\d+', '', raw_full_name).strip()
|
| 264 |
+
|
| 265 |
+
patient_data = {
|
| 266 |
+
'fhir_id': resource.get('id'),
|
| 267 |
+
'full_name': clean_full_name,
|
| 268 |
+
'gender': resource.get('gender', 'unknown'),
|
| 269 |
+
'date_of_birth': resource.get('birthDate', ''),
|
| 270 |
+
'address': ' '.join(address.get('line', [''])),
|
| 271 |
+
'city': address.get('city', ''),
|
| 272 |
+
'state': address.get('state', ''),
|
| 273 |
+
'postal_code': address.get('postalCode', ''),
|
| 274 |
+
'country': address.get('country', ''),
|
| 275 |
+
'marital_status': resource.get('maritalStatus', {}).get('text', ''),
|
| 276 |
+
'language': standardize_language(resource.get('communication', [{}])[0].get('language', {}).get('text', '')),
|
| 277 |
+
'source': 'synthea',
|
| 278 |
+
'last_updated': datetime.utcnow().isoformat()
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
elif resource_type == 'Encounter':
|
| 282 |
+
encounter = {
|
| 283 |
+
'id': resource.get('id'),
|
| 284 |
+
'type': resource.get('type', [{}])[0].get('text', ''),
|
| 285 |
+
'status': resource.get('status'),
|
| 286 |
+
'period': resource.get('period', {}),
|
| 287 |
+
'service_provider': resource.get('serviceProvider', {}).get('display', '')
|
| 288 |
+
}
|
| 289 |
+
encounters.append(encounter)
|
| 290 |
+
|
| 291 |
+
for note in resource.get('note', []):
|
| 292 |
+
if note.get('text'):
|
| 293 |
+
notes.append({
|
| 294 |
+
'date': resource.get('period', {}).get('start', datetime.utcnow().isoformat()),
|
| 295 |
+
'type': resource.get('type', [{}])[0].get('text', 'Encounter Note'),
|
| 296 |
+
'text': note.get('text'),
|
| 297 |
+
'context': f"Encounter: {encounter.get('type')}",
|
| 298 |
+
'author': 'System Generated'
|
| 299 |
+
})
|
| 300 |
+
|
| 301 |
+
elif resource_type == 'Condition':
|
| 302 |
+
conditions.append({
|
| 303 |
+
'id': resource.get('id'),
|
| 304 |
+
'code': resource.get('code', {}).get('text', ''),
|
| 305 |
+
'status': resource.get('clinicalStatus', {}).get('text', ''),
|
| 306 |
+
'onset_date': resource.get('onsetDateTime'),
|
| 307 |
+
'recorded_date': resource.get('recordedDate'),
|
| 308 |
+
'verification_status': resource.get('verificationStatus', {}).get('text', '')
|
| 309 |
+
})
|
| 310 |
+
|
| 311 |
+
elif resource_type == 'MedicationRequest':
|
| 312 |
+
medications.append({
|
| 313 |
+
'id': resource.get('id'),
|
| 314 |
+
'name': resource.get('medicationCodeableConcept', {}).get('text', ''),
|
| 315 |
+
'status': resource.get('status'),
|
| 316 |
+
'prescribed_date': resource.get('authoredOn'),
|
| 317 |
+
'requester': resource.get('requester', {}).get('display', ''),
|
| 318 |
+
'dosage': resource.get('dosageInstruction', [{}])[0].get('text', '')
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
except Exception as e:
|
| 322 |
+
logger.error(f"Error processing {resource_type} in {file_path}: {str(e)}")
|
| 323 |
+
continue
|
| 324 |
+
|
| 325 |
+
if patient_data:
|
| 326 |
+
patient_data.update({
|
| 327 |
+
'notes': notes,
|
| 328 |
+
'conditions': conditions,
|
| 329 |
+
'medications': medications,
|
| 330 |
+
'encounters': encounters,
|
| 331 |
+
'import_date': datetime.utcnow().isoformat()
|
| 332 |
+
})
|
| 333 |
+
logger.info(f"Successfully processed patient {patient_data.get('fhir_id')} from {file_path}")
|
| 334 |
+
return patient_data
|
| 335 |
+
logger.warning(f"No valid patient data found in {file_path}")
|
| 336 |
+
return None
|
| 337 |
+
|
| 338 |
+
@router.post("/import", status_code=status.HTTP_201_CREATED)
|
| 339 |
+
async def import_patients(
|
| 340 |
+
limit: int = Query(100, ge=1, le=1000),
|
| 341 |
+
current_user: dict = Depends(get_current_user)
|
| 342 |
+
):
|
| 343 |
+
request_id = str(uuid.uuid4())
|
| 344 |
+
logger.info(f"Starting import request {request_id} by user {current_user.get('email')}")
|
| 345 |
+
start_time = time.time()
|
| 346 |
+
|
| 347 |
+
if current_user.get('role') not in ['admin', 'doctor']:
|
| 348 |
+
logger.warning(f"Unauthorized import attempt by {current_user.get('email')}")
|
| 349 |
+
raise HTTPException(
|
| 350 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 351 |
+
detail="Only administrators and doctors can import data"
|
| 352 |
+
)
|
| 353 |
+
|
| 354 |
+
try:
|
| 355 |
+
await create_indexes()
|
| 356 |
+
|
| 357 |
+
if not SYNTHEA_DATA_DIR.exists():
|
| 358 |
+
logger.error(f"Synthea data directory not found: {SYNTHEA_DATA_DIR}")
|
| 359 |
+
raise HTTPException(
|
| 360 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 361 |
+
detail="Data directory not found"
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
# Filter out non-patient files
|
| 365 |
+
files = [
|
| 366 |
+
f for f in glob.glob(str(SYNTHEA_DATA_DIR / "*.json"))
|
| 367 |
+
if not re.search(r'(hospitalInformation|practitionerInformation)\d+\.json$', f)
|
| 368 |
+
]
|
| 369 |
+
if not files:
|
| 370 |
+
logger.warning("No valid patient JSON files found in synthea data directory")
|
| 371 |
+
return {
|
| 372 |
+
"status": "success",
|
| 373 |
+
"message": "No patient data files found",
|
| 374 |
+
"imported": 0,
|
| 375 |
+
"request_id": request_id
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
operations = []
|
| 379 |
+
imported = 0
|
| 380 |
+
errors = []
|
| 381 |
+
|
| 382 |
+
for file_path in files[:limit]:
|
| 383 |
+
try:
|
| 384 |
+
logger.debug(f"Processing file: {file_path}")
|
| 385 |
+
|
| 386 |
+
# Check file accessibility
|
| 387 |
+
if not os.path.exists(file_path):
|
| 388 |
+
logger.error(f"File not found: {file_path}")
|
| 389 |
+
errors.append(f"File not found: {file_path}")
|
| 390 |
+
continue
|
| 391 |
+
|
| 392 |
+
# Check file size
|
| 393 |
+
file_size = os.path.getsize(file_path)
|
| 394 |
+
if file_size == 0:
|
| 395 |
+
logger.warning(f"Empty file: {file_path}")
|
| 396 |
+
errors.append(f"Empty file: {file_path}")
|
| 397 |
+
continue
|
| 398 |
+
|
| 399 |
+
with open(file_path, 'r', encoding='utf-8') as f:
|
| 400 |
+
try:
|
| 401 |
+
bundle = json.load(f)
|
| 402 |
+
except json.JSONDecodeError as je:
|
| 403 |
+
logger.error(f"Invalid JSON in {file_path}: {str(je)}")
|
| 404 |
+
errors.append(f"Invalid JSON in {file_path}: {str(je)}")
|
| 405 |
+
continue
|
| 406 |
+
|
| 407 |
+
patient = await process_synthea_patient(bundle, file_path)
|
| 408 |
+
if patient:
|
| 409 |
+
if not patient.get('fhir_id'):
|
| 410 |
+
logger.warning(f"Missing FHIR ID in patient data from {file_path}")
|
| 411 |
+
errors.append(f"Missing FHIR ID in {file_path}")
|
| 412 |
+
continue
|
| 413 |
+
|
| 414 |
+
operations.append(UpdateOne(
|
| 415 |
+
{"fhir_id": patient['fhir_id']},
|
| 416 |
+
{"$setOnInsert": patient},
|
| 417 |
+
upsert=True
|
| 418 |
+
))
|
| 419 |
+
imported += 1
|
| 420 |
+
else:
|
| 421 |
+
logger.warning(f"No valid patient data in {file_path}")
|
| 422 |
+
errors.append(f"No valid patient data in {file_path}")
|
| 423 |
+
|
| 424 |
+
except Exception as e:
|
| 425 |
+
logger.error(f"Error processing {file_path}: {str(e)}")
|
| 426 |
+
errors.append(f"Error in {file_path}: {str(e)}")
|
| 427 |
+
continue
|
| 428 |
+
|
| 429 |
+
response = {
|
| 430 |
+
"status": "success",
|
| 431 |
+
"imported": imported,
|
| 432 |
+
"errors": errors,
|
| 433 |
+
"request_id": request_id,
|
| 434 |
+
"duration_seconds": time.time() - start_time
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
if operations:
|
| 438 |
+
try:
|
| 439 |
+
result = await patients_collection.bulk_write(operations, ordered=False)
|
| 440 |
+
response.update({
|
| 441 |
+
"upserted": result.upserted_count,
|
| 442 |
+
"existing": len(operations) - result.upserted_count
|
| 443 |
+
})
|
| 444 |
+
logger.info(f"Import request {request_id} completed: {imported} patients processed, "
|
| 445 |
+
f"{result.upserted_count} upserted, {len(errors)} errors")
|
| 446 |
+
except BulkWriteError as bwe:
|
| 447 |
+
logger.error(f"Partial bulk write failure for request {request_id}: {str(bwe.details)}")
|
| 448 |
+
response.update({
|
| 449 |
+
"upserted": bwe.details.get('nUpserted', 0),
|
| 450 |
+
"existing": len(operations) - bwe.details.get('nUpserted', 0),
|
| 451 |
+
"write_errors": [
|
| 452 |
+
f"Index {err['index']}: {err['errmsg']}" for err in bwe.details.get('writeErrors', [])
|
| 453 |
+
]
|
| 454 |
+
})
|
| 455 |
+
logger.info(f"Import request {request_id} partially completed: {imported} patients processed, "
|
| 456 |
+
f"{response['upserted']} upserted, {len(errors)} errors")
|
| 457 |
+
except Exception as e:
|
| 458 |
+
logger.error(f"Bulk write failed for request {request_id}: {str(e)}")
|
| 459 |
+
raise HTTPException(
|
| 460 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 461 |
+
detail=f"Database operation failed: {str(e)}"
|
| 462 |
+
)
|
| 463 |
+
else:
|
| 464 |
+
logger.info(f"Import request {request_id} completed: No new patients to import, {len(errors)} errors")
|
| 465 |
+
response["message"] = "No new patients found to import"
|
| 466 |
+
|
| 467 |
+
return response
|
| 468 |
+
|
| 469 |
+
except HTTPException:
|
| 470 |
+
raise
|
| 471 |
+
except Exception as e:
|
| 472 |
+
logger.error(f"Import request {request_id} failed: {str(e)}", exc_info=True)
|
| 473 |
+
raise HTTPException(
|
| 474 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 475 |
+
detail=f"Import failed: {str(e)}"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
@router.post("/patients/import-ehr", status_code=status.HTTP_201_CREATED)
|
| 479 |
+
async def import_ehr_patients(
|
| 480 |
+
ehr_data: List[dict],
|
| 481 |
+
ehr_system: str = Query(..., description="Name of the EHR system"),
|
| 482 |
+
current_user: dict = Depends(get_current_user),
|
| 483 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 484 |
+
):
|
| 485 |
+
"""Import patients from external EHR system"""
|
| 486 |
+
logger.info(f"Importing {len(ehr_data)} patients from EHR system: {ehr_system}")
|
| 487 |
+
|
| 488 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 489 |
+
logger.warning(f"Unauthorized EHR import attempt by {current_user.get('email')}")
|
| 490 |
+
raise HTTPException(
|
| 491 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 492 |
+
detail="Only administrators and doctors can import EHR patients"
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
try:
|
| 496 |
+
imported_patients = []
|
| 497 |
+
skipped_patients = []
|
| 498 |
+
|
| 499 |
+
for patient_data in ehr_data:
|
| 500 |
+
# Check if patient already exists by multiple criteria
|
| 501 |
+
existing_patient = await patients_collection.find_one({
|
| 502 |
+
"$or": [
|
| 503 |
+
{"ehr_id": patient_data.get("ehr_id"), "ehr_system": ehr_system},
|
| 504 |
+
{"full_name": patient_data.get("full_name"), "date_of_birth": patient_data.get("date_of_birth")},
|
| 505 |
+
{"national_id": patient_data.get("national_id")} if patient_data.get("national_id") else {}
|
| 506 |
+
]
|
| 507 |
+
})
|
| 508 |
+
|
| 509 |
+
if existing_patient:
|
| 510 |
+
skipped_patients.append(patient_data.get("full_name", "Unknown"))
|
| 511 |
+
logger.info(f"Patient {patient_data.get('full_name', 'Unknown')} already exists, skipping...")
|
| 512 |
+
continue
|
| 513 |
+
|
| 514 |
+
# Prepare patient document for EHR import
|
| 515 |
+
patient_doc = {
|
| 516 |
+
"full_name": patient_data.get("full_name"),
|
| 517 |
+
"date_of_birth": patient_data.get("date_of_birth"),
|
| 518 |
+
"gender": patient_data.get("gender"),
|
| 519 |
+
"address": patient_data.get("address"),
|
| 520 |
+
"national_id": patient_data.get("national_id"),
|
| 521 |
+
"blood_type": patient_data.get("blood_type"),
|
| 522 |
+
"allergies": patient_data.get("allergies", []),
|
| 523 |
+
"chronic_conditions": patient_data.get("chronic_conditions", []),
|
| 524 |
+
"medications": patient_data.get("medications", []),
|
| 525 |
+
"emergency_contact_name": patient_data.get("emergency_contact_name"),
|
| 526 |
+
"emergency_contact_phone": patient_data.get("emergency_contact_phone"),
|
| 527 |
+
"insurance_provider": patient_data.get("insurance_provider"),
|
| 528 |
+
"insurance_policy_number": patient_data.get("insurance_policy_number"),
|
| 529 |
+
"contact": patient_data.get("contact"),
|
| 530 |
+
"source": "ehr",
|
| 531 |
+
"ehr_id": patient_data.get("ehr_id"),
|
| 532 |
+
"ehr_system": ehr_system,
|
| 533 |
+
"status": "active",
|
| 534 |
+
"registration_date": datetime.now(),
|
| 535 |
+
"created_by": current_user.get('email'),
|
| 536 |
+
"created_at": datetime.now(),
|
| 537 |
+
"updated_at": datetime.now()
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
# Insert patient
|
| 541 |
+
result = await patients_collection.insert_one(patient_doc)
|
| 542 |
+
imported_patients.append(patient_data.get("full_name", "Unknown"))
|
| 543 |
+
|
| 544 |
+
logger.info(f"Successfully imported {len(imported_patients)} patients, skipped {len(skipped_patients)}")
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
"message": f"Successfully imported {len(imported_patients)} patients from {ehr_system}",
|
| 548 |
+
"imported_count": len(imported_patients),
|
| 549 |
+
"skipped_count": len(skipped_patients),
|
| 550 |
+
"imported_patients": imported_patients,
|
| 551 |
+
"skipped_patients": skipped_patients
|
| 552 |
+
}
|
| 553 |
+
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.error(f"Error importing EHR patients: {str(e)}")
|
| 556 |
+
raise HTTPException(
|
| 557 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 558 |
+
detail=f"Error importing EHR patients: {str(e)}"
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
@router.get("/patients/sources", response_model=List[dict])
|
| 562 |
+
async def get_patient_sources(
|
| 563 |
+
current_user: dict = Depends(get_current_user)
|
| 564 |
+
):
|
| 565 |
+
"""Get available patient sources and their counts"""
|
| 566 |
+
try:
|
| 567 |
+
# Get counts for each source
|
| 568 |
+
source_counts = await patients_collection.aggregate([
|
| 569 |
+
{
|
| 570 |
+
"$group": {
|
| 571 |
+
"_id": "$source",
|
| 572 |
+
"count": {"$sum": 1}
|
| 573 |
+
}
|
| 574 |
+
}
|
| 575 |
+
]).to_list(length=None)
|
| 576 |
+
|
| 577 |
+
# Format the response
|
| 578 |
+
sources = []
|
| 579 |
+
for source_count in source_counts:
|
| 580 |
+
source_name = source_count["_id"] or "unknown"
|
| 581 |
+
sources.append({
|
| 582 |
+
"source": source_name,
|
| 583 |
+
"count": source_count["count"],
|
| 584 |
+
"label": source_name.replace("_", " ").title()
|
| 585 |
+
})
|
| 586 |
+
|
| 587 |
+
return sources
|
| 588 |
+
|
| 589 |
+
except Exception as e:
|
| 590 |
+
logger.error(f"Error getting patient sources: {str(e)}")
|
| 591 |
+
raise HTTPException(
|
| 592 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 593 |
+
detail=f"Error getting patient sources: {str(e)}"
|
| 594 |
+
)
|
| 595 |
+
|
| 596 |
+
@router.get("/patients", response_model=PatientListResponse)
|
| 597 |
+
async def get_patients(
|
| 598 |
+
page: int = Query(1, ge=1),
|
| 599 |
+
limit: int = Query(20, ge=1, le=100),
|
| 600 |
+
search: Optional[str] = Query(None),
|
| 601 |
+
source: Optional[str] = Query(None), # Filter by patient source
|
| 602 |
+
patient_status: Optional[str] = Query(None), # Filter by patient status
|
| 603 |
+
doctor_id: Optional[str] = Query(None), # Filter by assigned doctor
|
| 604 |
+
current_user: dict = Depends(get_current_user),
|
| 605 |
+
db_client: AsyncIOMotorClient = Depends(lambda: db)
|
| 606 |
+
):
|
| 607 |
+
"""Get patients with filtering options"""
|
| 608 |
+
skip = (page - 1) * limit
|
| 609 |
+
user_id = current_user["_id"]
|
| 610 |
+
|
| 611 |
+
# Debug logging
|
| 612 |
+
logger.info(f"🔍 Getting patients for user: {current_user.get('email')} with roles: {current_user.get('roles', [])}")
|
| 613 |
+
|
| 614 |
+
# Build filter query
|
| 615 |
+
filter_query = {}
|
| 616 |
+
|
| 617 |
+
# Role-based access - apply this first
|
| 618 |
+
if 'admin' not in current_user.get('roles', []):
|
| 619 |
+
if 'doctor' in current_user.get('roles', []):
|
| 620 |
+
# Doctors can see all patients for now (temporarily simplified)
|
| 621 |
+
logger.info("👨⚕️ Doctor access - no restrictions applied")
|
| 622 |
+
pass # No restrictions for doctors
|
| 623 |
+
else:
|
| 624 |
+
# Patients can only see their own record
|
| 625 |
+
logger.info(f"👤 Patient access - restricting to own record: {user_id}")
|
| 626 |
+
filter_query["_id"] = ObjectId(user_id)
|
| 627 |
+
|
| 628 |
+
# Build additional filters
|
| 629 |
+
additional_filters = {}
|
| 630 |
+
|
| 631 |
+
# Add search filter
|
| 632 |
+
if search:
|
| 633 |
+
additional_filters["$or"] = [
|
| 634 |
+
{"full_name": {"$regex": search, "$options": "i"}},
|
| 635 |
+
{"national_id": {"$regex": search, "$options": "i"}},
|
| 636 |
+
{"ehr_id": {"$regex": search, "$options": "i"}}
|
| 637 |
+
]
|
| 638 |
+
|
| 639 |
+
# Add source filter
|
| 640 |
+
if source:
|
| 641 |
+
additional_filters["source"] = source
|
| 642 |
+
|
| 643 |
+
# Add status filter
|
| 644 |
+
if patient_status:
|
| 645 |
+
additional_filters["status"] = patient_status
|
| 646 |
+
|
| 647 |
+
# Add doctor assignment filter
|
| 648 |
+
if doctor_id:
|
| 649 |
+
additional_filters["assigned_doctor_id"] = ObjectId(doctor_id)
|
| 650 |
+
|
| 651 |
+
# Combine filters
|
| 652 |
+
if additional_filters:
|
| 653 |
+
if filter_query.get("$or"):
|
| 654 |
+
# If we have role-based $or, we need to combine with additional filters
|
| 655 |
+
# Create a new $and condition
|
| 656 |
+
filter_query = {
|
| 657 |
+
"$and": [
|
| 658 |
+
filter_query,
|
| 659 |
+
additional_filters
|
| 660 |
+
]
|
| 661 |
+
}
|
| 662 |
+
else:
|
| 663 |
+
# No role-based restrictions, just use additional filters
|
| 664 |
+
filter_query.update(additional_filters)
|
| 665 |
+
|
| 666 |
+
logger.info(f"🔍 Final filter query: {filter_query}")
|
| 667 |
+
|
| 668 |
+
try:
|
| 669 |
+
# Get total count
|
| 670 |
+
total = await patients_collection.count_documents(filter_query)
|
| 671 |
+
logger.info(f"📊 Total patients matching filter: {total}")
|
| 672 |
+
|
| 673 |
+
# Get patients with pagination
|
| 674 |
+
patients_cursor = patients_collection.find(filter_query).skip(skip).limit(limit)
|
| 675 |
+
patients = await patients_cursor.to_list(length=limit)
|
| 676 |
+
logger.info(f"📋 Retrieved {len(patients)} patients")
|
| 677 |
+
|
| 678 |
+
# Process patients to include doctor names and format dates
|
| 679 |
+
processed_patients = []
|
| 680 |
+
for patient in patients:
|
| 681 |
+
# Get assigned doctor name if exists
|
| 682 |
+
assigned_doctor_name = None
|
| 683 |
+
if patient.get("assigned_doctor_id"):
|
| 684 |
+
doctor = await db_client.users.find_one({"_id": patient["assigned_doctor_id"]})
|
| 685 |
+
if doctor:
|
| 686 |
+
assigned_doctor_name = doctor.get("full_name")
|
| 687 |
+
|
| 688 |
+
# Convert ObjectId to string
|
| 689 |
+
patient["id"] = str(patient["_id"])
|
| 690 |
+
del patient["_id"]
|
| 691 |
+
|
| 692 |
+
# Add assigned doctor name
|
| 693 |
+
patient["assigned_doctor_name"] = assigned_doctor_name
|
| 694 |
+
|
| 695 |
+
processed_patients.append(patient)
|
| 696 |
+
|
| 697 |
+
logger.info(f"✅ Returning {len(processed_patients)} processed patients")
|
| 698 |
+
|
| 699 |
+
return PatientListResponse(
|
| 700 |
+
patients=processed_patients,
|
| 701 |
+
total=total,
|
| 702 |
+
page=page,
|
| 703 |
+
limit=limit,
|
| 704 |
+
source_filter=source,
|
| 705 |
+
status_filter=patient_status
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
except Exception as e:
|
| 709 |
+
logger.error(f"❌ Error fetching patients: {str(e)}")
|
| 710 |
+
raise HTTPException(
|
| 711 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 712 |
+
detail=f"Error fetching patients: {str(e)}"
|
| 713 |
+
)
|
| 714 |
+
|
| 715 |
+
@router.get("/patients/{patient_id}", response_model=dict)
|
| 716 |
+
async def get_patient(patient_id: str):
|
| 717 |
+
logger.info(f"Retrieving patient: {patient_id}")
|
| 718 |
+
try:
|
| 719 |
+
patient = await patients_collection.find_one({
|
| 720 |
+
"$or": [
|
| 721 |
+
{"_id": ObjectId(patient_id)},
|
| 722 |
+
{"fhir_id": patient_id}
|
| 723 |
+
]
|
| 724 |
+
})
|
| 725 |
+
|
| 726 |
+
if not patient:
|
| 727 |
+
logger.warning(f"Patient not found: {patient_id}")
|
| 728 |
+
raise HTTPException(
|
| 729 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 730 |
+
detail="Patient not found"
|
| 731 |
+
)
|
| 732 |
+
|
| 733 |
+
response = {
|
| 734 |
+
"demographics": {
|
| 735 |
+
"id": str(patient["_id"]),
|
| 736 |
+
"fhir_id": patient.get("fhir_id"),
|
| 737 |
+
"full_name": patient.get("full_name"),
|
| 738 |
+
"gender": patient.get("gender"),
|
| 739 |
+
"date_of_birth": patient.get("date_of_birth"),
|
| 740 |
+
"age": calculate_age(patient.get("date_of_birth")),
|
| 741 |
+
"address": {
|
| 742 |
+
"line": patient.get("address"),
|
| 743 |
+
"city": patient.get("city"),
|
| 744 |
+
"state": patient.get("state"),
|
| 745 |
+
"postal_code": patient.get("postal_code"),
|
| 746 |
+
"country": patient.get("country")
|
| 747 |
+
},
|
| 748 |
+
"marital_status": patient.get("marital_status"),
|
| 749 |
+
"language": patient.get("language")
|
| 750 |
+
},
|
| 751 |
+
"clinical_data": {
|
| 752 |
+
"notes": patient.get("notes", []),
|
| 753 |
+
"conditions": patient.get("conditions", []),
|
| 754 |
+
"medications": patient.get("medications", []),
|
| 755 |
+
"encounters": patient.get("encounters", [])
|
| 756 |
+
},
|
| 757 |
+
"metadata": {
|
| 758 |
+
"source": patient.get("source"),
|
| 759 |
+
"import_date": patient.get("import_date"),
|
| 760 |
+
"last_updated": patient.get("last_updated")
|
| 761 |
+
}
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
logger.info(f"Successfully retrieved patient: {patient_id}")
|
| 765 |
+
return response
|
| 766 |
+
|
| 767 |
+
except ValueError as ve:
|
| 768 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 769 |
+
raise HTTPException(
|
| 770 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 771 |
+
detail="Invalid patient ID format"
|
| 772 |
+
)
|
| 773 |
+
except Exception as e:
|
| 774 |
+
logger.error(f"Failed to retrieve patient {patient_id}: {str(e)}")
|
| 775 |
+
raise HTTPException(
|
| 776 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 777 |
+
detail=f"Failed to retrieve patient: {str(e)}"
|
| 778 |
+
)
|
| 779 |
+
|
| 780 |
+
@router.post("/{patient_id}/notes", status_code=status.HTTP_201_CREATED)
|
| 781 |
+
async def add_note(
|
| 782 |
+
patient_id: str,
|
| 783 |
+
note: Note,
|
| 784 |
+
current_user: dict = Depends(get_current_user)
|
| 785 |
+
):
|
| 786 |
+
logger.info(f"Adding note for patient {patient_id} by user {current_user.get('email')}")
|
| 787 |
+
if current_user.get('role') not in ['doctor', 'admin']:
|
| 788 |
+
logger.warning(f"Unauthorized note addition attempt by {current_user.get('email')}")
|
| 789 |
+
raise HTTPException(
|
| 790 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 791 |
+
detail="Only clinicians can add notes"
|
| 792 |
+
)
|
| 793 |
+
|
| 794 |
+
try:
|
| 795 |
+
note_data = note.dict()
|
| 796 |
+
note_data.update({
|
| 797 |
+
"author": current_user.get('full_name', 'System'),
|
| 798 |
+
"timestamp": datetime.utcnow().isoformat()
|
| 799 |
+
})
|
| 800 |
+
|
| 801 |
+
result = await patients_collection.update_one(
|
| 802 |
+
{"$or": [
|
| 803 |
+
{"_id": ObjectId(patient_id)},
|
| 804 |
+
{"fhir_id": patient_id}
|
| 805 |
+
]},
|
| 806 |
+
{
|
| 807 |
+
"$push": {"notes": note_data},
|
| 808 |
+
"$set": {"last_updated": datetime.utcnow().isoformat()}
|
| 809 |
+
}
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
if result.modified_count == 0:
|
| 813 |
+
logger.warning(f"Patient not found for note addition: {patient_id}")
|
| 814 |
+
raise HTTPException(
|
| 815 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 816 |
+
detail="Patient not found"
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
logger.info(f"Note added successfully for patient {patient_id}")
|
| 820 |
+
return {"status": "success", "message": "Note added"}
|
| 821 |
+
|
| 822 |
+
except ValueError as ve:
|
| 823 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 824 |
+
raise HTTPException(
|
| 825 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 826 |
+
detail="Invalid patient ID format"
|
| 827 |
+
)
|
| 828 |
+
except Exception as e:
|
| 829 |
+
logger.error(f"Failed to add note for patient {patient_id}: {str(e)}")
|
| 830 |
+
raise HTTPException(
|
| 831 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 832 |
+
detail=f"Failed to add note: {str(e)}"
|
| 833 |
+
)
|
| 834 |
+
|
| 835 |
+
@router.put("/patients/{patient_id}", status_code=status.HTTP_200_OK)
|
| 836 |
+
async def update_patient(
|
| 837 |
+
patient_id: str,
|
| 838 |
+
update_data: PatientUpdate,
|
| 839 |
+
current_user: dict = Depends(get_current_user)
|
| 840 |
+
):
|
| 841 |
+
"""Update a patient's record in the database"""
|
| 842 |
+
logger.info(f"Updating patient {patient_id} by user {current_user.get('email')}")
|
| 843 |
+
|
| 844 |
+
if not any(role in current_user.get('roles', []) for role in ['admin', 'doctor']):
|
| 845 |
+
logger.warning(f"Unauthorized update attempt by {current_user.get('email')}")
|
| 846 |
+
raise HTTPException(
|
| 847 |
+
status_code=status.HTTP_403_FORBIDDEN,
|
| 848 |
+
detail="Only administrators and doctors can update patients"
|
| 849 |
+
)
|
| 850 |
+
|
| 851 |
+
try:
|
| 852 |
+
# Build the query based on whether patient_id is a valid ObjectId
|
| 853 |
+
query = {"fhir_id": patient_id}
|
| 854 |
+
if ObjectId.is_valid(patient_id):
|
| 855 |
+
query = {
|
| 856 |
+
"$or": [
|
| 857 |
+
{"_id": ObjectId(patient_id)},
|
| 858 |
+
{"fhir_id": patient_id}
|
| 859 |
+
]
|
| 860 |
+
}
|
| 861 |
+
|
| 862 |
+
# Check if patient exists
|
| 863 |
+
patient = await patients_collection.find_one(query)
|
| 864 |
+
if not patient:
|
| 865 |
+
logger.warning(f"Patient not found for update: {patient_id}")
|
| 866 |
+
raise HTTPException(
|
| 867 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 868 |
+
detail="Patient not found"
|
| 869 |
+
)
|
| 870 |
+
|
| 871 |
+
# Prepare update operations
|
| 872 |
+
update_ops = {"$set": {"last_updated": datetime.utcnow().isoformat()}}
|
| 873 |
+
|
| 874 |
+
# Handle demographic updates
|
| 875 |
+
demographics = {
|
| 876 |
+
"full_name": update_data.full_name,
|
| 877 |
+
"gender": update_data.gender,
|
| 878 |
+
"date_of_birth": update_data.date_of_birth,
|
| 879 |
+
"address": update_data.address,
|
| 880 |
+
"city": update_data.city,
|
| 881 |
+
"state": update_data.state,
|
| 882 |
+
"postal_code": update_data.postal_code,
|
| 883 |
+
"country": update_data.country,
|
| 884 |
+
"marital_status": update_data.marital_status,
|
| 885 |
+
"language": update_data.language
|
| 886 |
+
}
|
| 887 |
+
for key, value in demographics.items():
|
| 888 |
+
if value is not None:
|
| 889 |
+
update_ops["$set"][key] = value
|
| 890 |
+
|
| 891 |
+
# Handle array updates (conditions, medications, encounters, notes)
|
| 892 |
+
array_fields = {
|
| 893 |
+
"conditions": update_data.conditions,
|
| 894 |
+
"medications": update_data.medications,
|
| 895 |
+
"encounters": update_data.encounters,
|
| 896 |
+
"notes": update_data.notes
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
for field, items in array_fields.items():
|
| 900 |
+
if items is not None:
|
| 901 |
+
# Fetch existing items
|
| 902 |
+
existing_items = patient.get(field, [])
|
| 903 |
+
updated_items = []
|
| 904 |
+
|
| 905 |
+
for item in items:
|
| 906 |
+
item_dict = item.dict(exclude_unset=True)
|
| 907 |
+
if not item_dict:
|
| 908 |
+
continue
|
| 909 |
+
|
| 910 |
+
# Generate ID for new items
|
| 911 |
+
if not item_dict.get("id"):
|
| 912 |
+
item_dict["id"] = str(uuid.uuid4())
|
| 913 |
+
|
| 914 |
+
# Validate required fields
|
| 915 |
+
if field == "conditions" and not item_dict.get("code"):
|
| 916 |
+
raise HTTPException(
|
| 917 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 918 |
+
detail=f"Condition code is required for {field}"
|
| 919 |
+
)
|
| 920 |
+
if field == "medications" and not item_dict.get("name"):
|
| 921 |
+
raise HTTPException(
|
| 922 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 923 |
+
detail=f"Medication name is required for {field}"
|
| 924 |
+
)
|
| 925 |
+
if field == "encounters" and not item_dict.get("type"):
|
| 926 |
+
raise HTTPException(
|
| 927 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 928 |
+
detail=f"Encounter type is required for {field}"
|
| 929 |
+
)
|
| 930 |
+
if field == "notes" and not item_dict.get("content"):
|
| 931 |
+
raise HTTPException(
|
| 932 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 933 |
+
detail=f"Note content is required for {field}"
|
| 934 |
+
)
|
| 935 |
+
|
| 936 |
+
updated_items.append(item_dict)
|
| 937 |
+
|
| 938 |
+
# Replace the entire array
|
| 939 |
+
update_ops["$set"][field] = updated_items
|
| 940 |
+
|
| 941 |
+
# Perform the update
|
| 942 |
+
result = await patients_collection.update_one(query, update_ops)
|
| 943 |
+
|
| 944 |
+
if result.modified_count == 0 and result.matched_count == 0:
|
| 945 |
+
logger.warning(f"Patient not found for update: {patient_id}")
|
| 946 |
+
raise HTTPException(
|
| 947 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 948 |
+
detail="Patient not found"
|
| 949 |
+
)
|
| 950 |
+
|
| 951 |
+
# Retrieve and return the updated patient
|
| 952 |
+
updated_patient = await patients_collection.find_one(query)
|
| 953 |
+
if not updated_patient:
|
| 954 |
+
logger.error(f"Failed to retrieve updated patient: {patient_id}")
|
| 955 |
+
raise HTTPException(
|
| 956 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 957 |
+
detail="Failed to retrieve updated patient"
|
| 958 |
+
)
|
| 959 |
+
|
| 960 |
+
response = {
|
| 961 |
+
"id": str(updated_patient["_id"]),
|
| 962 |
+
"fhir_id": updated_patient.get("fhir_id"),
|
| 963 |
+
"full_name": updated_patient.get("full_name"),
|
| 964 |
+
"gender": updated_patient.get("gender"),
|
| 965 |
+
"date_of_birth": updated_patient.get("date_of_birth"),
|
| 966 |
+
"address": updated_patient.get("address"),
|
| 967 |
+
"city": updated_patient.get("city"),
|
| 968 |
+
"state": updated_patient.get("state"),
|
| 969 |
+
"postal_code": updated_patient.get("postal_code"),
|
| 970 |
+
"country": updated_patient.get("country"),
|
| 971 |
+
"marital_status": updated_patient.get("marital_status"),
|
| 972 |
+
"language": updated_patient.get("language"),
|
| 973 |
+
"conditions": updated_patient.get("conditions", []),
|
| 974 |
+
"medications": updated_patient.get("medications", []),
|
| 975 |
+
"encounters": updated_patient.get("encounters", []),
|
| 976 |
+
"notes": updated_patient.get("notes", []),
|
| 977 |
+
"source": updated_patient.get("source"),
|
| 978 |
+
"import_date": updated_patient.get("import_date"),
|
| 979 |
+
"last_updated": updated_patient.get("last_updated")
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
logger.info(f"Successfully updated patient {patient_id}")
|
| 983 |
+
return response
|
| 984 |
+
|
| 985 |
+
except ValueError as ve:
|
| 986 |
+
logger.error(f"Invalid patient ID format: {patient_id}, error: {str(ve)}")
|
| 987 |
+
raise HTTPException(
|
| 988 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 989 |
+
detail="Invalid patient ID format"
|
| 990 |
+
)
|
| 991 |
+
except HTTPException:
|
| 992 |
+
raise
|
| 993 |
+
except Exception as e:
|
| 994 |
+
logger.error(f"Failed to update patient {patient_id}: {str(e)}")
|
| 995 |
+
raise HTTPException(
|
| 996 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 997 |
+
detail=f"Failed to update patient: {str(e)}"
|
| 998 |
+
)
|
| 999 |
+
|
| 1000 |
+
# FHIR Integration Endpoints
|
| 1001 |
+
@router.post("/patients/import-hapi-fhir", status_code=status.HTTP_201_CREATED)
|
| 1002 |
+
async def import_hapi_patients(
|
| 1003 |
+
limit: int = Query(20, ge=1, le=100, description="Number of patients to import"),
|
| 1004 |
+
current_user: dict = Depends(get_current_user)
|
| 1005 |
+
):
|
| 1006 |
+
"""
|
| 1007 |
+
Import patients from HAPI FHIR Test Server
|
| 1008 |
+
"""
|
| 1009 |
+
try:
|
| 1010 |
+
service = HAPIFHIRIntegrationService()
|
| 1011 |
+
result = await service.import_patients_from_hapi(limit=limit)
|
| 1012 |
+
|
| 1013 |
+
# Create detailed message
|
| 1014 |
+
message_parts = []
|
| 1015 |
+
if result["imported_count"] > 0:
|
| 1016 |
+
message_parts.append(f"Successfully imported {result['imported_count']} patients")
|
| 1017 |
+
if result["skipped_count"] > 0:
|
| 1018 |
+
message_parts.append(f"Skipped {result['skipped_count']} duplicate patients")
|
| 1019 |
+
if result["errors"]:
|
| 1020 |
+
message_parts.append(f"Encountered {len(result['errors'])} errors")
|
| 1021 |
+
|
| 1022 |
+
message = ". ".join(message_parts) + " from HAPI FHIR"
|
| 1023 |
+
|
| 1024 |
+
return {
|
| 1025 |
+
"message": message,
|
| 1026 |
+
"imported_count": result["imported_count"],
|
| 1027 |
+
"skipped_count": result["skipped_count"],
|
| 1028 |
+
"total_found": result["total_found"],
|
| 1029 |
+
"imported_patients": result["imported_patients"],
|
| 1030 |
+
"skipped_patients": result["skipped_patients"],
|
| 1031 |
+
"errors": result["errors"],
|
| 1032 |
+
"source": "hapi_fhir"
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
except Exception as e:
|
| 1036 |
+
logger.error(f"Error importing HAPI FHIR patients: {str(e)}")
|
| 1037 |
+
raise HTTPException(
|
| 1038 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1039 |
+
detail=f"Failed to import patients from HAPI FHIR: {str(e)}"
|
| 1040 |
+
)
|
| 1041 |
+
|
| 1042 |
+
@router.post("/patients/sync-patient/{patient_id}")
|
| 1043 |
+
async def sync_patient_data(
|
| 1044 |
+
patient_id: str,
|
| 1045 |
+
current_user: dict = Depends(get_current_user)
|
| 1046 |
+
):
|
| 1047 |
+
"""
|
| 1048 |
+
Sync a specific patient's data from HAPI FHIR
|
| 1049 |
+
"""
|
| 1050 |
+
try:
|
| 1051 |
+
service = HAPIFHIRIntegrationService()
|
| 1052 |
+
success = await service.sync_patient_data(patient_id)
|
| 1053 |
+
|
| 1054 |
+
if success:
|
| 1055 |
+
return {
|
| 1056 |
+
"message": f"Successfully synced patient {patient_id} from HAPI FHIR",
|
| 1057 |
+
"patient_id": patient_id,
|
| 1058 |
+
"success": True
|
| 1059 |
+
}
|
| 1060 |
+
else:
|
| 1061 |
+
raise HTTPException(
|
| 1062 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1063 |
+
detail=f"Patient {patient_id} not found in HAPI FHIR or sync failed"
|
| 1064 |
+
)
|
| 1065 |
+
|
| 1066 |
+
except HTTPException:
|
| 1067 |
+
raise
|
| 1068 |
+
except Exception as e:
|
| 1069 |
+
logger.error(f"Error syncing patient {patient_id}: {str(e)}")
|
| 1070 |
+
raise HTTPException(
|
| 1071 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1072 |
+
detail=f"Failed to sync patient: {str(e)}"
|
| 1073 |
+
)
|
| 1074 |
+
|
| 1075 |
+
@router.get("/patients/hapi-fhir/patients")
|
| 1076 |
+
async def get_hapi_patients(
|
| 1077 |
+
limit: int = Query(50, ge=1, le=200, description="Number of patients to fetch"),
|
| 1078 |
+
current_user: dict = Depends(get_current_user)
|
| 1079 |
+
):
|
| 1080 |
+
"""
|
| 1081 |
+
Get patients from HAPI FHIR without importing them
|
| 1082 |
+
"""
|
| 1083 |
+
try:
|
| 1084 |
+
service = HAPIFHIRIntegrationService()
|
| 1085 |
+
patients = await service.get_hapi_patients(limit=limit)
|
| 1086 |
+
|
| 1087 |
+
return {
|
| 1088 |
+
"patients": patients,
|
| 1089 |
+
"count": len(patients),
|
| 1090 |
+
"source": "hapi_fhir"
|
| 1091 |
+
}
|
| 1092 |
+
|
| 1093 |
+
except Exception as e:
|
| 1094 |
+
logger.error(f"Error fetching HAPI FHIR patients: {str(e)}")
|
| 1095 |
+
raise HTTPException(
|
| 1096 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1097 |
+
detail=f"Failed to fetch patients from HAPI FHIR: {str(e)}"
|
| 1098 |
+
)
|
| 1099 |
+
|
| 1100 |
+
@router.get("/patients/hapi-fhir/patients/{patient_id}")
|
| 1101 |
+
async def get_hapi_patient_details(
|
| 1102 |
+
patient_id: str,
|
| 1103 |
+
current_user: dict = Depends(get_current_user)
|
| 1104 |
+
):
|
| 1105 |
+
"""
|
| 1106 |
+
Get detailed information for a specific HAPI FHIR patient
|
| 1107 |
+
"""
|
| 1108 |
+
try:
|
| 1109 |
+
service = HAPIFHIRIntegrationService()
|
| 1110 |
+
patient_details = await service.get_hapi_patient_details(patient_id)
|
| 1111 |
+
|
| 1112 |
+
if not patient_details:
|
| 1113 |
+
raise HTTPException(
|
| 1114 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 1115 |
+
detail=f"Patient {patient_id} not found in HAPI FHIR"
|
| 1116 |
+
)
|
| 1117 |
+
|
| 1118 |
+
return patient_details
|
| 1119 |
+
|
| 1120 |
+
except HTTPException:
|
| 1121 |
+
raise
|
| 1122 |
+
except Exception as e:
|
| 1123 |
+
logger.error(f"Error fetching HAPI FHIR patient details: {str(e)}")
|
| 1124 |
+
raise HTTPException(
|
| 1125 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1126 |
+
detail=f"Failed to fetch patient details from HAPI FHIR: {str(e)}"
|
| 1127 |
+
)
|
| 1128 |
+
|
| 1129 |
+
@router.get("/patients/hapi-fhir/statistics")
|
| 1130 |
+
async def get_hapi_statistics(
|
| 1131 |
+
current_user: dict = Depends(get_current_user)
|
| 1132 |
+
):
|
| 1133 |
+
"""
|
| 1134 |
+
Get statistics about HAPI FHIR imported patients
|
| 1135 |
+
"""
|
| 1136 |
+
try:
|
| 1137 |
+
service = HAPIFHIRIntegrationService()
|
| 1138 |
+
stats = await service.get_patient_statistics()
|
| 1139 |
+
|
| 1140 |
+
return {
|
| 1141 |
+
"statistics": stats,
|
| 1142 |
+
"source": "hapi_fhir"
|
| 1143 |
+
}
|
| 1144 |
+
|
| 1145 |
+
except Exception as e:
|
| 1146 |
+
logger.error(f"Error getting HAPI FHIR statistics: {str(e)}")
|
| 1147 |
+
raise HTTPException(
|
| 1148 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1149 |
+
detail=f"Failed to get HAPI FHIR statistics: {str(e)}"
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
# Export the router as 'patients' for api.__init__.py
|
| 1153 |
+
patients = router
|
routes/pdf.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 16 |
+
level=logging.INFO,
|
| 17 |
+
format='%(asctime)s - %(levelname)s - %(name)s - %(message)s'
|
| 18 |
+
)
|
| 19 |
+
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 with proper LaTeX formatting
|
| 44 |
+
def prepare_table_content(items, columns, default_message):
|
| 45 |
+
if not items:
|
| 46 |
+
return f"\\multicolumn{{{columns}}}{{l}}{{{default_message}}} \\\\"
|
| 47 |
+
|
| 48 |
+
content = []
|
| 49 |
+
for item in items:
|
| 50 |
+
row = []
|
| 51 |
+
for field in item:
|
| 52 |
+
value = item.get(field, "") or ""
|
| 53 |
+
row.append(escape_latex_special_chars(hyphenate_long_strings(value)))
|
| 54 |
+
content.append(" & ".join(row) + " \\\\")
|
| 55 |
+
return "\n".join(content)
|
| 56 |
+
|
| 57 |
+
# Notes table
|
| 58 |
+
notes = patient.get("notes", [])
|
| 59 |
+
notes_content = prepare_table_content(
|
| 60 |
+
[{
|
| 61 |
+
"date": format_timestamp(n.get("date", "")),
|
| 62 |
+
"type": n.get("type", ""),
|
| 63 |
+
"text": n.get("text", "")
|
| 64 |
+
} for n in notes],
|
| 65 |
+
3,
|
| 66 |
+
"No notes available"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# Conditions table
|
| 70 |
+
conditions = patient.get("conditions", [])
|
| 71 |
+
conditions_content = prepare_table_content(
|
| 72 |
+
[{
|
| 73 |
+
"id": c.get("id", ""),
|
| 74 |
+
"code": c.get("code", ""),
|
| 75 |
+
"status": c.get("status", ""),
|
| 76 |
+
"onset": format_timestamp(c.get("onset_date", "")),
|
| 77 |
+
"verification": c.get("verification_status", "")
|
| 78 |
+
} for c in conditions],
|
| 79 |
+
5,
|
| 80 |
+
"No conditions available"
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
# Medications table
|
| 84 |
+
medications = patient.get("medications", [])
|
| 85 |
+
medications_content = prepare_table_content(
|
| 86 |
+
[{
|
| 87 |
+
"id": m.get("id", ""),
|
| 88 |
+
"name": m.get("name", ""),
|
| 89 |
+
"status": m.get("status", ""),
|
| 90 |
+
"date": format_timestamp(m.get("prescribed_date", "")),
|
| 91 |
+
"dosage": m.get("dosage", "")
|
| 92 |
+
} for m in medications],
|
| 93 |
+
5,
|
| 94 |
+
"No medications available"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
# Encounters table
|
| 98 |
+
encounters = patient.get("encounters", [])
|
| 99 |
+
encounters_content = prepare_table_content(
|
| 100 |
+
[{
|
| 101 |
+
"id": e.get("id", ""),
|
| 102 |
+
"type": e.get("type", ""),
|
| 103 |
+
"status": e.get("status", ""),
|
| 104 |
+
"start": format_timestamp(e.get("period", {}).get("start", "")),
|
| 105 |
+
"provider": e.get("service_provider", "")
|
| 106 |
+
} for e in encounters],
|
| 107 |
+
5,
|
| 108 |
+
"No encounters available"
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
# LaTeX template with improved table formatting
|
| 112 |
+
latex_template = Template(r"""
|
| 113 |
+
\documentclass[a4paper,12pt]{article}
|
| 114 |
+
\usepackage[utf8]{inputenc}
|
| 115 |
+
\usepackage[T1]{fontenc}
|
| 116 |
+
\usepackage{geometry}
|
| 117 |
+
\geometry{margin=1in}
|
| 118 |
+
\usepackage{booktabs,longtable,fancyhdr}
|
| 119 |
+
\usepackage{array}
|
| 120 |
+
\usepackage{microtype}
|
| 121 |
+
\microtypesetup{expansion=false}
|
| 122 |
+
\setlength{\headheight}{14.5pt}
|
| 123 |
+
\pagestyle{fancy}
|
| 124 |
+
\fancyhf{}
|
| 125 |
+
\fancyhead[L]{Patient Report}
|
| 126 |
+
\fancyhead[R]{Generated: \today}
|
| 127 |
+
\fancyfoot[C]{\thepage}
|
| 128 |
+
\begin{document}
|
| 129 |
+
\begin{center}
|
| 130 |
+
\Large\textbf{Patient Medical Report} \\
|
| 131 |
+
\vspace{0.2cm}
|
| 132 |
+
\textit{Generated on $generated_on}
|
| 133 |
+
\end{center}
|
| 134 |
+
\section*{Demographics}
|
| 135 |
+
\begin{itemize}
|
| 136 |
+
\item \textbf{FHIR ID:} $fhir_id
|
| 137 |
+
\item \textbf{Full Name:} $full_name
|
| 138 |
+
\item \textbf{Gender:} $gender
|
| 139 |
+
\item \textbf{Date of Birth:} $dob
|
| 140 |
+
\item \textbf{Age:} $age
|
| 141 |
+
\item \textbf{Address:} $address
|
| 142 |
+
\item \textbf{Marital Status:} $marital_status
|
| 143 |
+
\item \textbf{Language:} $language
|
| 144 |
+
\end{itemize}
|
| 145 |
+
\section*{Clinical Notes}
|
| 146 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{6.5cm}}
|
| 147 |
+
\caption{Clinical Notes} \\
|
| 148 |
+
\toprule
|
| 149 |
+
\textbf{Date} & \textbf{Type} & \textbf{Text} \\
|
| 150 |
+
\midrule
|
| 151 |
+
$notes
|
| 152 |
+
\bottomrule
|
| 153 |
+
\end{longtable}
|
| 154 |
+
\section*{Conditions}
|
| 155 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
|
| 156 |
+
\caption{Conditions} \\
|
| 157 |
+
\toprule
|
| 158 |
+
\textbf{ID} & \textbf{Code} & \textbf{Status} & \textbf{Onset} & \textbf{Verification} \\
|
| 159 |
+
\midrule
|
| 160 |
+
$conditions
|
| 161 |
+
\bottomrule
|
| 162 |
+
\end{longtable}
|
| 163 |
+
\section*{Medications}
|
| 164 |
+
\begin{longtable}[l]{>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{4cm}>{\raggedright\arraybackslash}p{2cm}>{\raggedright\arraybackslash}p{3.5cm}>{\raggedright\arraybackslash}p{3cm}}
|
| 165 |
+
\caption{Medications} \\
|
| 166 |
+
\toprule
|
| 167 |
+
\textbf{ID} & \textbf{Name} & \textbf{Status} & \textbf{Date} & \textbf{Dosage} \\
|
| 168 |
+
\midrule
|
| 169 |
+
$medications
|
| 170 |
+
\bottomrule
|
| 171 |
+
\end{longtable}
|
| 172 |
+
\section*{Encounters}
|
| 173 |
+
\begin{longtable}[l]{>{\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}}
|
| 174 |
+
\caption{Encounters} \\
|
| 175 |
+
\toprule
|
| 176 |
+
\textbf{ID} & \textbf{Type} & \textbf{Status} & \textbf{Start} & \textbf{Provider} \\
|
| 177 |
+
\midrule
|
| 178 |
+
$encounters
|
| 179 |
+
\bottomrule
|
| 180 |
+
\end{longtable}
|
| 181 |
+
\end{document}
|
| 182 |
+
""")
|
| 183 |
+
|
| 184 |
+
# Set the generated_on date to 02:54 PM CET, May 17, 2025
|
| 185 |
+
generated_on = datetime.strptime("2025-05-17 14:54:00+02:00", "%Y-%m-%d %H:%M:%S%z").strftime("%A, %B %d, %Y at %I:%M %p %Z")
|
| 186 |
+
|
| 187 |
+
latex_filled = latex_template.substitute(
|
| 188 |
+
generated_on=generated_on,
|
| 189 |
+
fhir_id=escape_latex_special_chars(hyphenate_long_strings(patient.get("fhir_id", "") or "")),
|
| 190 |
+
full_name=escape_latex_special_chars(patient.get("full_name", "") or ""),
|
| 191 |
+
gender=escape_latex_special_chars(patient.get("gender", "") or ""),
|
| 192 |
+
dob=escape_latex_special_chars(patient.get("date_of_birth", "") or ""),
|
| 193 |
+
age=escape_latex_special_chars(str(calculate_age(patient.get("date_of_birth", "")) or "N/A")),
|
| 194 |
+
address=escape_latex_special_chars(", ".join(filter(None, [
|
| 195 |
+
patient.get("address", ""),
|
| 196 |
+
patient.get("city", ""),
|
| 197 |
+
patient.get("state", ""),
|
| 198 |
+
patient.get("postal_code", ""),
|
| 199 |
+
patient.get("country", "")
|
| 200 |
+
]))),
|
| 201 |
+
marital_status=escape_latex_special_chars(patient.get("marital_status", "") or ""),
|
| 202 |
+
language=escape_latex_special_chars(patient.get("language", "") or ""),
|
| 203 |
+
notes=notes_content,
|
| 204 |
+
conditions=conditions_content,
|
| 205 |
+
medications=medications_content,
|
| 206 |
+
encounters=encounters_content
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
# Compile LaTeX in a temporary directory
|
| 210 |
+
with TemporaryDirectory() as tmpdir:
|
| 211 |
+
tex_path = os.path.join(tmpdir, "report.tex")
|
| 212 |
+
pdf_path = os.path.join(tmpdir, "report.pdf")
|
| 213 |
+
|
| 214 |
+
with open(tex_path, "w", encoding="utf-8") as f:
|
| 215 |
+
f.write(latex_filled)
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
# Run latexmk twice to ensure proper table rendering
|
| 219 |
+
for _ in range(2):
|
| 220 |
+
result = subprocess.run(
|
| 221 |
+
["latexmk", "-pdf", "-interaction=nonstopmode", tex_path],
|
| 222 |
+
cwd=tmpdir,
|
| 223 |
+
check=False,
|
| 224 |
+
capture_output=True,
|
| 225 |
+
text=True
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
if result.returncode != 0:
|
| 229 |
+
raise HTTPException(
|
| 230 |
+
status_code=500,
|
| 231 |
+
detail=f"LaTeX compilation failed: stdout={result.stdout}, stderr={result.stderr}"
|
| 232 |
+
)
|
| 233 |
+
|
| 234 |
+
except subprocess.CalledProcessError as e:
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
status_code=500,
|
| 237 |
+
detail=f"LaTeX compilation failed: stdout={e.stdout}, stderr={e.stderr}"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
if not os.path.exists(pdf_path):
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=500,
|
| 243 |
+
detail="PDF file was not generated"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
with open(pdf_path, "rb") as f:
|
| 247 |
+
pdf_bytes = f.read()
|
| 248 |
+
|
| 249 |
+
response = Response(
|
| 250 |
+
content=pdf_bytes,
|
| 251 |
+
media_type="application/pdf",
|
| 252 |
+
headers={"Content-Disposition": f"attachment; filename=patient_{patient.get('fhir_id', 'unknown')}_report.pdf"}
|
| 253 |
+
)
|
| 254 |
+
return response
|
| 255 |
+
|
| 256 |
+
except HTTPException as http_error:
|
| 257 |
+
raise http_error
|
| 258 |
+
except Exception as e:
|
| 259 |
+
raise HTTPException(
|
| 260 |
+
status_code=500,
|
| 261 |
+
detail=f"Unexpected error generating PDF: {str(e)}"
|
| 262 |
+
)
|
| 263 |
+
finally:
|
| 264 |
+
# Restore the logger level for other routes
|
| 265 |
+
logger.setLevel(logging.INFO)
|
| 266 |
+
|
| 267 |
+
# Export the router as 'pdf' for api.__init__.py
|
| 268 |
+
pdf = router
|
routes/txagent.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
| 2 |
+
from fastapi.responses import StreamingResponse
|
| 3 |
+
from typing import Optional, List
|
| 4 |
+
from pydantic import BaseModel
|
| 5 |
+
from core.security import get_current_user
|
| 6 |
+
from api.services.txagent_service import txagent_service
|
| 7 |
+
import logging
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
class ChatRequest(BaseModel):
|
| 14 |
+
message: str
|
| 15 |
+
history: Optional[List[dict]] = None
|
| 16 |
+
patient_id: Optional[str] = None
|
| 17 |
+
|
| 18 |
+
class VoiceOutputRequest(BaseModel):
|
| 19 |
+
text: str
|
| 20 |
+
language: str = "en-US"
|
| 21 |
+
|
| 22 |
+
@router.get("/txagent/status")
|
| 23 |
+
async def get_txagent_status(current_user: dict = Depends(get_current_user)):
|
| 24 |
+
"""Obtient le statut du service TxAgent"""
|
| 25 |
+
try:
|
| 26 |
+
status = await txagent_service.get_status()
|
| 27 |
+
return {
|
| 28 |
+
"status": "success",
|
| 29 |
+
"txagent_status": status,
|
| 30 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 31 |
+
}
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Error getting TxAgent status: {e}")
|
| 34 |
+
raise HTTPException(status_code=500, detail="Failed to get TxAgent status")
|
| 35 |
+
|
| 36 |
+
@router.post("/txagent/chat")
|
| 37 |
+
async def chat_with_txagent(
|
| 38 |
+
request: ChatRequest,
|
| 39 |
+
current_user: dict = Depends(get_current_user)
|
| 40 |
+
):
|
| 41 |
+
"""Chat avec TxAgent"""
|
| 42 |
+
try:
|
| 43 |
+
# Vérifier que l'utilisateur est médecin ou admin
|
| 44 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 45 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use TxAgent")
|
| 46 |
+
|
| 47 |
+
response = await txagent_service.chat(
|
| 48 |
+
message=request.message,
|
| 49 |
+
history=request.history,
|
| 50 |
+
patient_id=request.patient_id
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
return {
|
| 54 |
+
"status": "success",
|
| 55 |
+
"response": response,
|
| 56 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 57 |
+
}
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.error(f"Error in TxAgent chat: {e}")
|
| 60 |
+
raise HTTPException(status_code=500, detail="Failed to process chat request")
|
| 61 |
+
|
| 62 |
+
@router.post("/txagent/voice/transcribe")
|
| 63 |
+
async def transcribe_audio(
|
| 64 |
+
audio: UploadFile = File(...),
|
| 65 |
+
current_user: dict = Depends(get_current_user)
|
| 66 |
+
):
|
| 67 |
+
"""Transcription vocale avec TxAgent"""
|
| 68 |
+
try:
|
| 69 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 70 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
|
| 71 |
+
|
| 72 |
+
audio_data = await audio.read()
|
| 73 |
+
result = await txagent_service.voice_transcribe(audio_data)
|
| 74 |
+
|
| 75 |
+
return {
|
| 76 |
+
"status": "success",
|
| 77 |
+
"transcription": result,
|
| 78 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 79 |
+
}
|
| 80 |
+
except Exception as e:
|
| 81 |
+
logger.error(f"Error in voice transcription: {e}")
|
| 82 |
+
raise HTTPException(status_code=500, detail="Failed to transcribe audio")
|
| 83 |
+
|
| 84 |
+
@router.post("/txagent/voice/synthesize")
|
| 85 |
+
async def synthesize_speech(
|
| 86 |
+
request: VoiceOutputRequest,
|
| 87 |
+
current_user: dict = Depends(get_current_user)
|
| 88 |
+
):
|
| 89 |
+
"""Synthèse vocale avec TxAgent"""
|
| 90 |
+
try:
|
| 91 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 92 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use voice features")
|
| 93 |
+
|
| 94 |
+
audio_data = await txagent_service.voice_synthesize(
|
| 95 |
+
text=request.text,
|
| 96 |
+
language=request.language
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
return StreamingResponse(
|
| 100 |
+
iter([audio_data]),
|
| 101 |
+
media_type="audio/mpeg",
|
| 102 |
+
headers={
|
| 103 |
+
"Content-Disposition": "attachment; filename=synthesized_speech.mp3"
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
logger.error(f"Error in voice synthesis: {e}")
|
| 108 |
+
raise HTTPException(status_code=500, detail="Failed to synthesize speech")
|
| 109 |
+
|
| 110 |
+
@router.post("/txagent/patients/analyze")
|
| 111 |
+
async def analyze_patient_data(
|
| 112 |
+
patient_data: dict,
|
| 113 |
+
current_user: dict = Depends(get_current_user)
|
| 114 |
+
):
|
| 115 |
+
"""Analyse de données patient avec TxAgent"""
|
| 116 |
+
try:
|
| 117 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 118 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can use analysis features")
|
| 119 |
+
|
| 120 |
+
analysis = await txagent_service.analyze_patient(patient_data)
|
| 121 |
+
|
| 122 |
+
return {
|
| 123 |
+
"status": "success",
|
| 124 |
+
"analysis": analysis,
|
| 125 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 126 |
+
}
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Error in patient analysis: {e}")
|
| 129 |
+
raise HTTPException(status_code=500, detail="Failed to analyze patient data")
|
| 130 |
+
|
| 131 |
+
@router.get("/txagent/chats")
|
| 132 |
+
async def get_chats(current_user: dict = Depends(get_current_user)):
|
| 133 |
+
"""Obtient l'historique des chats"""
|
| 134 |
+
try:
|
| 135 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 136 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can access chats")
|
| 137 |
+
|
| 138 |
+
# Cette fonction devra être implémentée dans le service TxAgent
|
| 139 |
+
chats = await txagent_service.get_chats()
|
| 140 |
+
|
| 141 |
+
return {
|
| 142 |
+
"status": "success",
|
| 143 |
+
"chats": chats,
|
| 144 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 145 |
+
}
|
| 146 |
+
except Exception as e:
|
| 147 |
+
logger.error(f"Error getting chats: {e}")
|
| 148 |
+
raise HTTPException(status_code=500, detail="Failed to get chats")
|
| 149 |
+
|
| 150 |
+
@router.get("/txagent/patients/analysis-results")
|
| 151 |
+
async def get_analysis_results(
|
| 152 |
+
risk_filter: Optional[str] = None,
|
| 153 |
+
current_user: dict = Depends(get_current_user)
|
| 154 |
+
):
|
| 155 |
+
"""Obtient les résultats d'analyse des patients"""
|
| 156 |
+
try:
|
| 157 |
+
if not any(role in current_user.get('roles', []) for role in ['doctor', 'admin']):
|
| 158 |
+
raise HTTPException(status_code=403, detail="Only doctors and admins can access analysis results")
|
| 159 |
+
|
| 160 |
+
# Cette fonction devra être implémentée dans le service TxAgent
|
| 161 |
+
results = await txagent_service.get_analysis_results(risk_filter)
|
| 162 |
+
|
| 163 |
+
return {
|
| 164 |
+
"status": "success",
|
| 165 |
+
"results": results,
|
| 166 |
+
"mode": txagent_service.config.get_txagent_mode()
|
| 167 |
+
}
|
| 168 |
+
except Exception as e:
|
| 169 |
+
logger.error(f"Error getting analysis results: {e}")
|
| 170 |
+
raise HTTPException(status_code=500, detail="Failed to get analysis results")
|
services/fhir_integration.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime
|
| 2 |
+
from typing import List, Dict, Optional
|
| 3 |
+
from api.utils.fhir_client import HAPIFHIRClient
|
| 4 |
+
from db.mongo import db
|
| 5 |
+
|
| 6 |
+
class HAPIFHIRIntegrationService:
|
| 7 |
+
"""
|
| 8 |
+
Service to integrate HAPI FHIR data with your existing database
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
def __init__(self):
|
| 12 |
+
self.fhir_client = HAPIFHIRClient()
|
| 13 |
+
|
| 14 |
+
async def import_patients_from_hapi(self, limit: int = 20) -> dict:
|
| 15 |
+
"""
|
| 16 |
+
Import patients from HAPI FHIR Test Server with detailed feedback
|
| 17 |
+
"""
|
| 18 |
+
try:
|
| 19 |
+
print(f"Fetching {limit} patients from HAPI FHIR...")
|
| 20 |
+
patients = self.fhir_client.get_patients(limit=limit)
|
| 21 |
+
|
| 22 |
+
if not patients:
|
| 23 |
+
print("No patients found in HAPI FHIR")
|
| 24 |
+
return {
|
| 25 |
+
"imported_count": 0,
|
| 26 |
+
"skipped_count": 0,
|
| 27 |
+
"total_found": 0,
|
| 28 |
+
"imported_patients": [],
|
| 29 |
+
"skipped_patients": [],
|
| 30 |
+
"errors": []
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
print(f"Found {len(patients)} patients, checking for duplicates...")
|
| 34 |
+
|
| 35 |
+
imported_count = 0
|
| 36 |
+
skipped_count = 0
|
| 37 |
+
imported_patients = []
|
| 38 |
+
skipped_patients = []
|
| 39 |
+
errors = []
|
| 40 |
+
|
| 41 |
+
for patient in patients:
|
| 42 |
+
try:
|
| 43 |
+
# Check if patient already exists by multiple criteria
|
| 44 |
+
existing = await db.patients.find_one({
|
| 45 |
+
"$or": [
|
| 46 |
+
{"fhir_id": patient['fhir_id']},
|
| 47 |
+
{"full_name": patient['full_name'], "date_of_birth": patient['date_of_birth']},
|
| 48 |
+
{"demographics.fhir_id": patient['fhir_id']}
|
| 49 |
+
]
|
| 50 |
+
})
|
| 51 |
+
|
| 52 |
+
if existing:
|
| 53 |
+
skipped_count += 1
|
| 54 |
+
skipped_patients.append(patient['full_name'])
|
| 55 |
+
print(f"Patient {patient['full_name']} already exists (fhir_id: {patient['fhir_id']}), skipping...")
|
| 56 |
+
continue
|
| 57 |
+
|
| 58 |
+
# Enhance patient data with additional FHIR data
|
| 59 |
+
enhanced_patient = await self._enhance_patient_data(patient)
|
| 60 |
+
|
| 61 |
+
# Insert into database
|
| 62 |
+
result = await db.patients.insert_one(enhanced_patient)
|
| 63 |
+
|
| 64 |
+
if result.inserted_id:
|
| 65 |
+
imported_count += 1
|
| 66 |
+
imported_patients.append(patient['full_name'])
|
| 67 |
+
print(f"Imported patient: {patient['full_name']} (ID: {result.inserted_id})")
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
error_msg = f"Error importing patient {patient.get('full_name', 'Unknown')}: {e}"
|
| 71 |
+
errors.append(error_msg)
|
| 72 |
+
print(error_msg)
|
| 73 |
+
continue
|
| 74 |
+
|
| 75 |
+
print(f"Import completed: {imported_count} imported, {skipped_count} skipped")
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"imported_count": imported_count,
|
| 79 |
+
"skipped_count": skipped_count,
|
| 80 |
+
"total_found": len(patients),
|
| 81 |
+
"imported_patients": imported_patients,
|
| 82 |
+
"skipped_patients": skipped_patients,
|
| 83 |
+
"errors": errors
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
except Exception as e:
|
| 87 |
+
print(f"Error importing patients: {e}")
|
| 88 |
+
return {
|
| 89 |
+
"imported_count": 0,
|
| 90 |
+
"skipped_count": 0,
|
| 91 |
+
"total_found": 0,
|
| 92 |
+
"imported_patients": [],
|
| 93 |
+
"skipped_patients": [],
|
| 94 |
+
"errors": [str(e)]
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
async def _enhance_patient_data(self, patient: Dict) -> Dict:
|
| 98 |
+
"""
|
| 99 |
+
Enhance patient data with additional FHIR resources
|
| 100 |
+
"""
|
| 101 |
+
try:
|
| 102 |
+
patient_id = patient['fhir_id']
|
| 103 |
+
|
| 104 |
+
# Fetch additional data from HAPI FHIR
|
| 105 |
+
observations = self.fhir_client.get_patient_observations(patient_id)
|
| 106 |
+
medications = self.fhir_client.get_patient_medications(patient_id)
|
| 107 |
+
conditions = self.fhir_client.get_patient_conditions(patient_id)
|
| 108 |
+
|
| 109 |
+
# Structure the enhanced patient data
|
| 110 |
+
enhanced_patient = {
|
| 111 |
+
# Basic demographics
|
| 112 |
+
**patient,
|
| 113 |
+
|
| 114 |
+
# Clinical data
|
| 115 |
+
'demographics': {
|
| 116 |
+
'id': patient['id'],
|
| 117 |
+
'fhir_id': patient['fhir_id'],
|
| 118 |
+
'full_name': patient['full_name'],
|
| 119 |
+
'gender': patient['gender'],
|
| 120 |
+
'date_of_birth': patient['date_of_birth'],
|
| 121 |
+
'address': patient['address'],
|
| 122 |
+
'phone': patient.get('phone', ''),
|
| 123 |
+
'email': patient.get('email', ''),
|
| 124 |
+
'marital_status': patient.get('marital_status', 'Unknown'),
|
| 125 |
+
'language': patient.get('language', 'English')
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
'clinical_data': {
|
| 129 |
+
'observations': observations,
|
| 130 |
+
'medications': medications,
|
| 131 |
+
'conditions': conditions,
|
| 132 |
+
'notes': [], # Will be populated separately
|
| 133 |
+
'encounters': [] # Will be populated separately
|
| 134 |
+
},
|
| 135 |
+
|
| 136 |
+
'metadata': {
|
| 137 |
+
'source': 'hapi_fhir',
|
| 138 |
+
'import_date': datetime.now().isoformat(),
|
| 139 |
+
'last_updated': datetime.now().isoformat(),
|
| 140 |
+
'fhir_server': 'https://hapi.fhir.org/baseR4'
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
return enhanced_patient
|
| 145 |
+
|
| 146 |
+
except Exception as e:
|
| 147 |
+
print(f"Error enhancing patient data: {e}")
|
| 148 |
+
return patient
|
| 149 |
+
|
| 150 |
+
async def sync_patient_data(self, patient_id: str) -> bool:
|
| 151 |
+
"""
|
| 152 |
+
Sync a specific patient's data from HAPI FHIR
|
| 153 |
+
"""
|
| 154 |
+
try:
|
| 155 |
+
# Get patient from HAPI FHIR
|
| 156 |
+
patient = self.fhir_client.get_patient_by_id(patient_id)
|
| 157 |
+
|
| 158 |
+
if not patient:
|
| 159 |
+
print(f"Patient {patient_id} not found in HAPI FHIR")
|
| 160 |
+
return False
|
| 161 |
+
|
| 162 |
+
# Enhance with additional data
|
| 163 |
+
enhanced_patient = await self._enhance_patient_data(patient)
|
| 164 |
+
|
| 165 |
+
# Update in database
|
| 166 |
+
result = await db.patients.update_one(
|
| 167 |
+
{"fhir_id": patient_id},
|
| 168 |
+
{"$set": enhanced_patient},
|
| 169 |
+
upsert=True
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
if result.modified_count > 0 or result.upserted_id:
|
| 173 |
+
print(f"Synced patient: {patient['full_name']}")
|
| 174 |
+
return True
|
| 175 |
+
else:
|
| 176 |
+
print(f"No changes for patient: {patient['full_name']}")
|
| 177 |
+
return False
|
| 178 |
+
|
| 179 |
+
except Exception as e:
|
| 180 |
+
print(f"Error syncing patient {patient_id}: {e}")
|
| 181 |
+
return False
|
| 182 |
+
|
| 183 |
+
async def get_patient_statistics(self) -> Dict:
|
| 184 |
+
"""
|
| 185 |
+
Get statistics about imported patients
|
| 186 |
+
"""
|
| 187 |
+
try:
|
| 188 |
+
total_patients = await db.patients.count_documents({})
|
| 189 |
+
hapi_patients = await db.patients.count_documents({"source": "hapi_fhir"})
|
| 190 |
+
|
| 191 |
+
# Get sample patient data and convert ObjectId to string
|
| 192 |
+
sample_patient = await db.patients.find_one({"source": "hapi_fhir"})
|
| 193 |
+
if sample_patient:
|
| 194 |
+
# Convert ObjectId to string for JSON serialization
|
| 195 |
+
sample_patient['_id'] = str(sample_patient['_id'])
|
| 196 |
+
|
| 197 |
+
stats = {
|
| 198 |
+
'total_patients': total_patients,
|
| 199 |
+
'hapi_fhir_patients': hapi_patients,
|
| 200 |
+
'sample_patient': sample_patient
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
return stats
|
| 204 |
+
|
| 205 |
+
except Exception as e:
|
| 206 |
+
print(f"Error getting statistics: {e}")
|
| 207 |
+
return {}
|
| 208 |
+
|
| 209 |
+
async def get_hapi_patients(self, limit: int = 50) -> List[Dict]:
|
| 210 |
+
"""
|
| 211 |
+
Get patients from HAPI FHIR without importing them
|
| 212 |
+
"""
|
| 213 |
+
try:
|
| 214 |
+
patients = self.fhir_client.get_patients(limit=limit)
|
| 215 |
+
return patients
|
| 216 |
+
except Exception as e:
|
| 217 |
+
print(f"Error fetching HAPI patients: {e}")
|
| 218 |
+
return []
|
| 219 |
+
|
| 220 |
+
async def get_hapi_patient_details(self, patient_id: str) -> Optional[Dict]:
|
| 221 |
+
"""
|
| 222 |
+
Get detailed information for a specific HAPI FHIR patient
|
| 223 |
+
"""
|
| 224 |
+
try:
|
| 225 |
+
patient = self.fhir_client.get_patient_by_id(patient_id)
|
| 226 |
+
if not patient:
|
| 227 |
+
return None
|
| 228 |
+
|
| 229 |
+
# Get additional data
|
| 230 |
+
observations = self.fhir_client.get_patient_observations(patient_id)
|
| 231 |
+
medications = self.fhir_client.get_patient_medications(patient_id)
|
| 232 |
+
conditions = self.fhir_client.get_patient_conditions(patient_id)
|
| 233 |
+
|
| 234 |
+
return {
|
| 235 |
+
'patient': patient,
|
| 236 |
+
'observations': observations,
|
| 237 |
+
'medications': medications,
|
| 238 |
+
'conditions': conditions
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
except Exception as e:
|
| 242 |
+
print(f"Error fetching patient details: {e}")
|
| 243 |
+
return None
|
services/txagent_service.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import aiohttp
|
| 2 |
+
import asyncio
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Optional, Dict, Any, List
|
| 5 |
+
from core.txagent_config import txagent_config
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
class TxAgentService:
|
| 10 |
+
def __init__(self):
|
| 11 |
+
self.config = txagent_config
|
| 12 |
+
self.session = None
|
| 13 |
+
|
| 14 |
+
async def _get_session(self):
|
| 15 |
+
"""Obtient ou crée une session HTTP"""
|
| 16 |
+
if self.session is None:
|
| 17 |
+
self.session = aiohttp.ClientSession()
|
| 18 |
+
return self.session
|
| 19 |
+
|
| 20 |
+
async def _make_request(self, endpoint: str, method: str = "GET", data: Optional[Dict] = None) -> Dict[str, Any]:
|
| 21 |
+
"""Fait une requête vers le service TxAgent avec fallback"""
|
| 22 |
+
session = await self._get_session()
|
| 23 |
+
url = f"{self.config.get_txagent_url()}{endpoint}"
|
| 24 |
+
|
| 25 |
+
try:
|
| 26 |
+
if method.upper() == "GET":
|
| 27 |
+
async with session.get(url) as response:
|
| 28 |
+
return await response.json()
|
| 29 |
+
elif method.upper() == "POST":
|
| 30 |
+
async with session.post(url, json=data) as response:
|
| 31 |
+
return await response.json()
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logger.error(f"Error calling TxAgent service: {e}")
|
| 34 |
+
# Fallback vers cloud si local échoue
|
| 35 |
+
if self.config.get_txagent_mode() == "local":
|
| 36 |
+
logger.info("Falling back to cloud TxAgent service")
|
| 37 |
+
self.config.mode = "cloud"
|
| 38 |
+
return await self._make_request(endpoint, method, data)
|
| 39 |
+
else:
|
| 40 |
+
raise
|
| 41 |
+
|
| 42 |
+
async def chat(self, message: str, history: Optional[list] = None, patient_id: Optional[str] = None) -> Dict[str, Any]:
|
| 43 |
+
"""Service de chat avec TxAgent"""
|
| 44 |
+
data = {
|
| 45 |
+
"message": message,
|
| 46 |
+
"history": history or [],
|
| 47 |
+
"patient_id": patient_id
|
| 48 |
+
}
|
| 49 |
+
return await self._make_request("/chat", "POST", data)
|
| 50 |
+
|
| 51 |
+
async def analyze_patient(self, patient_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 52 |
+
"""Analyse de données patient avec TxAgent"""
|
| 53 |
+
return await self._make_request("/patients/analyze", "POST", patient_data)
|
| 54 |
+
|
| 55 |
+
async def voice_transcribe(self, audio_data: bytes) -> Dict[str, Any]:
|
| 56 |
+
"""Transcription vocale avec TxAgent"""
|
| 57 |
+
session = await self._get_session()
|
| 58 |
+
url = f"{self.config.get_txagent_url()}/voice/transcribe"
|
| 59 |
+
|
| 60 |
+
try:
|
| 61 |
+
form_data = aiohttp.FormData()
|
| 62 |
+
form_data.add_field('audio', audio_data, filename='audio.wav')
|
| 63 |
+
|
| 64 |
+
async with session.post(url, data=form_data) as response:
|
| 65 |
+
return await response.json()
|
| 66 |
+
except Exception as e:
|
| 67 |
+
logger.error(f"Error in voice transcription: {e}")
|
| 68 |
+
if self.config.get_txagent_mode() == "local":
|
| 69 |
+
self.config.mode = "cloud"
|
| 70 |
+
return await self.voice_transcribe(audio_data)
|
| 71 |
+
else:
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
async def voice_synthesize(self, text: str, language: str = "en-US") -> bytes:
|
| 75 |
+
"""Synthèse vocale avec TxAgent"""
|
| 76 |
+
session = await self._get_session()
|
| 77 |
+
url = f"{self.config.get_txagent_url()}/voice/synthesize"
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
data = {
|
| 81 |
+
"text": text,
|
| 82 |
+
"language": language,
|
| 83 |
+
"return_format": "mp3"
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
async with session.post(url, json=data) as response:
|
| 87 |
+
return await response.read()
|
| 88 |
+
except Exception as e:
|
| 89 |
+
logger.error(f"Error in voice synthesis: {e}")
|
| 90 |
+
if self.config.get_txagent_mode() == "local":
|
| 91 |
+
self.config.mode = "cloud"
|
| 92 |
+
return await self.voice_synthesize(text, language)
|
| 93 |
+
else:
|
| 94 |
+
raise
|
| 95 |
+
|
| 96 |
+
async def get_status(self) -> Dict[str, Any]:
|
| 97 |
+
"""Obtient le statut du service TxAgent"""
|
| 98 |
+
return await self._make_request("/status")
|
| 99 |
+
|
| 100 |
+
async def get_chats(self) -> List[Dict[str, Any]]:
|
| 101 |
+
"""Obtient l'historique des chats"""
|
| 102 |
+
return await self._make_request("/chats")
|
| 103 |
+
|
| 104 |
+
async def get_analysis_results(self, risk_filter: Optional[str] = None) -> List[Dict[str, Any]]:
|
| 105 |
+
"""Obtient les résultats d'analyse des patients"""
|
| 106 |
+
params = {}
|
| 107 |
+
if risk_filter:
|
| 108 |
+
params["risk_filter"] = risk_filter
|
| 109 |
+
return await self._make_request("/patients/analysis-results", "GET", params)
|
| 110 |
+
|
| 111 |
+
async def close(self):
|
| 112 |
+
"""Ferme la session HTTP"""
|
| 113 |
+
if self.session:
|
| 114 |
+
await self.session.close()
|
| 115 |
+
self.session = None
|
| 116 |
+
|
| 117 |
+
# Instance globale
|
| 118 |
+
txagent_service = TxAgentService()
|
utils/fhir_client.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
from typing import List, Dict, Optional
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
class HAPIFHIRClient:
|
| 7 |
+
"""
|
| 8 |
+
Client for connecting to HAPI FHIR Test Server
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
def __init__(self, base_url: str = "https://hapi.fhir.org/baseR4"):
|
| 12 |
+
self.base_url = base_url
|
| 13 |
+
self.session = requests.Session()
|
| 14 |
+
self.session.headers.update({
|
| 15 |
+
'Content-Type': 'application/fhir+json',
|
| 16 |
+
'Accept': 'application/fhir+json'
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
def get_patients(self, limit: int = 50) -> List[Dict]:
|
| 20 |
+
"""
|
| 21 |
+
Fetch patients from HAPI FHIR Test Server
|
| 22 |
+
"""
|
| 23 |
+
try:
|
| 24 |
+
url = f"{self.base_url}/Patient"
|
| 25 |
+
params = {
|
| 26 |
+
'_count': limit,
|
| 27 |
+
'_format': 'json'
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
response = self.session.get(url, params=params)
|
| 31 |
+
response.raise_for_status()
|
| 32 |
+
|
| 33 |
+
data = response.json()
|
| 34 |
+
patients = []
|
| 35 |
+
|
| 36 |
+
if 'entry' in data:
|
| 37 |
+
for entry in data['entry']:
|
| 38 |
+
patient = self._parse_patient(entry['resource'])
|
| 39 |
+
if patient:
|
| 40 |
+
patients.append(patient)
|
| 41 |
+
|
| 42 |
+
return patients
|
| 43 |
+
|
| 44 |
+
except requests.RequestException as e:
|
| 45 |
+
print(f"Error fetching patients: {e}")
|
| 46 |
+
return []
|
| 47 |
+
|
| 48 |
+
def get_patient_by_id(self, patient_id: str) -> Optional[Dict]:
|
| 49 |
+
"""
|
| 50 |
+
Fetch a specific patient by ID
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
url = f"{self.base_url}/Patient/{patient_id}"
|
| 54 |
+
response = self.session.get(url)
|
| 55 |
+
response.raise_for_status()
|
| 56 |
+
|
| 57 |
+
patient_data = response.json()
|
| 58 |
+
return self._parse_patient(patient_data)
|
| 59 |
+
|
| 60 |
+
except requests.RequestException as e:
|
| 61 |
+
print(f"Error fetching patient {patient_id}: {e}")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
def get_patient_observations(self, patient_id: str) -> List[Dict]:
|
| 65 |
+
"""
|
| 66 |
+
Fetch observations (vital signs, lab results) for a patient
|
| 67 |
+
"""
|
| 68 |
+
try:
|
| 69 |
+
url = f"{self.base_url}/Observation"
|
| 70 |
+
params = {
|
| 71 |
+
'subject': f"Patient/{patient_id}",
|
| 72 |
+
'_count': 100,
|
| 73 |
+
'_format': 'json'
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
response = self.session.get(url, params=params)
|
| 77 |
+
response.raise_for_status()
|
| 78 |
+
|
| 79 |
+
data = response.json()
|
| 80 |
+
observations = []
|
| 81 |
+
|
| 82 |
+
if 'entry' in data:
|
| 83 |
+
for entry in data['entry']:
|
| 84 |
+
observation = self._parse_observation(entry['resource'])
|
| 85 |
+
if observation:
|
| 86 |
+
observations.append(observation)
|
| 87 |
+
|
| 88 |
+
return observations
|
| 89 |
+
|
| 90 |
+
except requests.RequestException as e:
|
| 91 |
+
print(f"Error fetching observations for patient {patient_id}: {e}")
|
| 92 |
+
return []
|
| 93 |
+
|
| 94 |
+
def get_patient_medications(self, patient_id: str) -> List[Dict]:
|
| 95 |
+
"""
|
| 96 |
+
Fetch medications for a patient
|
| 97 |
+
"""
|
| 98 |
+
try:
|
| 99 |
+
url = f"{self.base_url}/MedicationRequest"
|
| 100 |
+
params = {
|
| 101 |
+
'subject': f"Patient/{patient_id}",
|
| 102 |
+
'_count': 100,
|
| 103 |
+
'_format': 'json'
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
response = self.session.get(url, params=params)
|
| 107 |
+
response.raise_for_status()
|
| 108 |
+
|
| 109 |
+
data = response.json()
|
| 110 |
+
medications = []
|
| 111 |
+
|
| 112 |
+
if 'entry' in data:
|
| 113 |
+
for entry in data['entry']:
|
| 114 |
+
medication = self._parse_medication(entry['resource'])
|
| 115 |
+
if medication:
|
| 116 |
+
medications.append(medication)
|
| 117 |
+
|
| 118 |
+
return medications
|
| 119 |
+
|
| 120 |
+
except requests.RequestException as e:
|
| 121 |
+
print(f"Error fetching medications for patient {patient_id}: {e}")
|
| 122 |
+
return []
|
| 123 |
+
|
| 124 |
+
def get_patient_conditions(self, patient_id: str) -> List[Dict]:
|
| 125 |
+
"""
|
| 126 |
+
Fetch conditions (diagnoses) for a patient
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
url = f"{self.base_url}/Condition"
|
| 130 |
+
params = {
|
| 131 |
+
'subject': f"Patient/{patient_id}",
|
| 132 |
+
'_count': 100,
|
| 133 |
+
'_format': 'json'
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
response = self.session.get(url, params=params)
|
| 137 |
+
response.raise_for_status()
|
| 138 |
+
|
| 139 |
+
data = response.json()
|
| 140 |
+
conditions = []
|
| 141 |
+
|
| 142 |
+
if 'entry' in data:
|
| 143 |
+
for entry in data['entry']:
|
| 144 |
+
condition = self._parse_condition(entry['resource'])
|
| 145 |
+
if condition:
|
| 146 |
+
conditions.append(condition)
|
| 147 |
+
|
| 148 |
+
return conditions
|
| 149 |
+
|
| 150 |
+
except requests.RequestException as e:
|
| 151 |
+
print(f"Error fetching conditions for patient {patient_id}: {e}")
|
| 152 |
+
return []
|
| 153 |
+
|
| 154 |
+
def _parse_patient(self, patient_data: Dict) -> Optional[Dict]:
|
| 155 |
+
"""
|
| 156 |
+
Parse FHIR Patient resource into our format
|
| 157 |
+
"""
|
| 158 |
+
try:
|
| 159 |
+
# Extract basic demographics
|
| 160 |
+
name = ""
|
| 161 |
+
if 'name' in patient_data and patient_data['name']:
|
| 162 |
+
name_parts = patient_data['name'][0]
|
| 163 |
+
given = name_parts.get('given', [])
|
| 164 |
+
family = name_parts.get('family', '')
|
| 165 |
+
name = f"{' '.join(given)} {family}".strip()
|
| 166 |
+
|
| 167 |
+
# Extract address
|
| 168 |
+
address = ""
|
| 169 |
+
if 'address' in patient_data and patient_data['address']:
|
| 170 |
+
addr = patient_data['address'][0]
|
| 171 |
+
line = addr.get('line', [])
|
| 172 |
+
city = addr.get('city', '')
|
| 173 |
+
state = addr.get('state', '')
|
| 174 |
+
postal_code = addr.get('postalCode', '')
|
| 175 |
+
address = f"{', '.join(line)}, {city}, {state} {postal_code}".strip()
|
| 176 |
+
|
| 177 |
+
# Extract contact info
|
| 178 |
+
phone = ""
|
| 179 |
+
email = ""
|
| 180 |
+
if 'telecom' in patient_data:
|
| 181 |
+
for telecom in patient_data['telecom']:
|
| 182 |
+
if telecom.get('system') == 'phone':
|
| 183 |
+
phone = telecom.get('value', '')
|
| 184 |
+
elif telecom.get('system') == 'email':
|
| 185 |
+
email = telecom.get('value', '')
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
'id': patient_data.get('id', ''),
|
| 189 |
+
'fhir_id': patient_data.get('id', ''),
|
| 190 |
+
'full_name': name,
|
| 191 |
+
'gender': patient_data.get('gender', 'unknown'),
|
| 192 |
+
'date_of_birth': patient_data.get('birthDate', ''),
|
| 193 |
+
'address': address,
|
| 194 |
+
'phone': phone,
|
| 195 |
+
'email': email,
|
| 196 |
+
'marital_status': self._get_marital_status(patient_data),
|
| 197 |
+
'language': self._get_language(patient_data),
|
| 198 |
+
'source': 'hapi_fhir',
|
| 199 |
+
'status': 'active',
|
| 200 |
+
'created_at': datetime.now().isoformat(),
|
| 201 |
+
'updated_at': datetime.now().isoformat()
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"Error parsing patient data: {e}")
|
| 206 |
+
return None
|
| 207 |
+
|
| 208 |
+
def _parse_observation(self, observation_data: Dict) -> Optional[Dict]:
|
| 209 |
+
"""
|
| 210 |
+
Parse FHIR Observation resource
|
| 211 |
+
"""
|
| 212 |
+
try:
|
| 213 |
+
code = observation_data.get('code', {})
|
| 214 |
+
coding = code.get('coding', [])
|
| 215 |
+
code_text = code.get('text', '')
|
| 216 |
+
|
| 217 |
+
if coding:
|
| 218 |
+
code_text = coding[0].get('display', code_text)
|
| 219 |
+
|
| 220 |
+
value = observation_data.get('valueQuantity', {})
|
| 221 |
+
unit = value.get('unit', '')
|
| 222 |
+
value_amount = value.get('value', '')
|
| 223 |
+
|
| 224 |
+
return {
|
| 225 |
+
'id': observation_data.get('id', ''),
|
| 226 |
+
'code': code_text,
|
| 227 |
+
'value': f"{value_amount} {unit}".strip(),
|
| 228 |
+
'date': observation_data.get('effectiveDateTime', ''),
|
| 229 |
+
'category': self._get_observation_category(observation_data)
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
print(f"Error parsing observation: {e}")
|
| 234 |
+
return None
|
| 235 |
+
|
| 236 |
+
def _parse_medication(self, medication_data: Dict) -> Optional[Dict]:
|
| 237 |
+
"""
|
| 238 |
+
Parse FHIR MedicationRequest resource
|
| 239 |
+
"""
|
| 240 |
+
try:
|
| 241 |
+
medication = medication_data.get('medicationCodeableConcept', {})
|
| 242 |
+
coding = medication.get('coding', [])
|
| 243 |
+
name = medication.get('text', '')
|
| 244 |
+
|
| 245 |
+
if coding:
|
| 246 |
+
name = coding[0].get('display', name)
|
| 247 |
+
|
| 248 |
+
dosage = medication_data.get('dosageInstruction', [])
|
| 249 |
+
dosage_text = ""
|
| 250 |
+
if dosage:
|
| 251 |
+
dosage_text = dosage[0].get('text', '')
|
| 252 |
+
|
| 253 |
+
return {
|
| 254 |
+
'id': medication_data.get('id', ''),
|
| 255 |
+
'name': name,
|
| 256 |
+
'dosage': dosage_text,
|
| 257 |
+
'status': medication_data.get('status', 'active'),
|
| 258 |
+
'prescribed_date': medication_data.get('authoredOn', ''),
|
| 259 |
+
'requester': self._get_practitioner_name(medication_data)
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
except Exception as e:
|
| 263 |
+
print(f"Error parsing medication: {e}")
|
| 264 |
+
return None
|
| 265 |
+
|
| 266 |
+
def _parse_condition(self, condition_data: Dict) -> Optional[Dict]:
|
| 267 |
+
"""
|
| 268 |
+
Parse FHIR Condition resource
|
| 269 |
+
"""
|
| 270 |
+
try:
|
| 271 |
+
code = condition_data.get('code', {})
|
| 272 |
+
coding = code.get('coding', [])
|
| 273 |
+
name = code.get('text', '')
|
| 274 |
+
|
| 275 |
+
if coding:
|
| 276 |
+
name = coding[0].get('display', name)
|
| 277 |
+
|
| 278 |
+
return {
|
| 279 |
+
'id': condition_data.get('id', ''),
|
| 280 |
+
'code': name,
|
| 281 |
+
'status': condition_data.get('clinicalStatus', {}).get('coding', [{}])[0].get('code', 'active'),
|
| 282 |
+
'onset_date': condition_data.get('onsetDateTime', ''),
|
| 283 |
+
'recorded_date': condition_data.get('recordedDate', ''),
|
| 284 |
+
'notes': condition_data.get('note', [{}])[0].get('text', '') if condition_data.get('note') else ''
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
except Exception as e:
|
| 288 |
+
print(f"Error parsing condition: {e}")
|
| 289 |
+
return None
|
| 290 |
+
|
| 291 |
+
def _get_marital_status(self, patient_data: Dict) -> str:
|
| 292 |
+
"""Extract marital status from patient data"""
|
| 293 |
+
if 'maritalStatus' in patient_data:
|
| 294 |
+
coding = patient_data['maritalStatus'].get('coding', [])
|
| 295 |
+
if coding:
|
| 296 |
+
return coding[0].get('display', 'Unknown')
|
| 297 |
+
return 'Unknown'
|
| 298 |
+
|
| 299 |
+
def _get_language(self, patient_data: Dict) -> str:
|
| 300 |
+
"""Extract language from patient data"""
|
| 301 |
+
if 'communication' in patient_data and patient_data['communication']:
|
| 302 |
+
language = patient_data['communication'][0].get('language', {})
|
| 303 |
+
coding = language.get('coding', [])
|
| 304 |
+
if coding:
|
| 305 |
+
return coding[0].get('display', 'English')
|
| 306 |
+
return 'English'
|
| 307 |
+
|
| 308 |
+
def _get_observation_category(self, observation_data: Dict) -> str:
|
| 309 |
+
"""Extract observation category"""
|
| 310 |
+
category = observation_data.get('category', {})
|
| 311 |
+
coding = category.get('coding', [])
|
| 312 |
+
if coding:
|
| 313 |
+
return coding[0].get('display', 'Unknown')
|
| 314 |
+
return 'Unknown'
|
| 315 |
+
|
| 316 |
+
def _get_practitioner_name(self, medication_data: Dict) -> str:
|
| 317 |
+
"""Extract practitioner name from medication request"""
|
| 318 |
+
requester = medication_data.get('requester', {})
|
| 319 |
+
reference = requester.get('reference', '')
|
| 320 |
+
if reference.startswith('Practitioner/'):
|
| 321 |
+
# In a real implementation, you'd fetch the practitioner details
|
| 322 |
+
return 'Dr. Practitioner'
|
| 323 |
+
return 'Unknown'
|