Files
sriphat-dataplatform/03-apiservice/app/api/v1/routes.py
jigoong e4d32b86cb feat: add VOC data endpoint (POST /api/v1/voc-data)
- Add VocDataIn schema (date, topic, sub_topic, level, depart_id, dep_name)
- Add RawVocData SQLAlchemy model (rawdata.raw_voc_data, BIGSERIAL PK)
- Add POST /api/v1/voc-data endpoint with voc.data:write permission
- Dual-write to local PostgreSQL + Supabase
- Table auto-created on startup via Base.metadata.create_all()
2026-06-04 18:22:14 +07:00

346 lines
12 KiB
Python

from __future__ import annotations
import logging
from typing import Annotated
from datetime import datetime
from zoneinfo import ZoneInfo
from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn, VocDataIn
from app.core.config import settings
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment, RawVocData
from app.security.dependencies import get_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/v1")
PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write"
PERM_FEED_OLD_CHECKPOINT_WRITE = "feed.old-checkpoint:write"
PERM_FEED_PATIENT_APPOINTMENT_WRITE = "feed.patient-appointment:write"
PERM_VOC_DATA_WRITE = "voc.data:write"
def _to_tz(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=ZoneInfo(settings.TIMEZONE))
return dt.astimezone(ZoneInfo(settings.TIMEZONE))
def _to_iso(dt):
"""Convert datetime to ISO 8601 string for Supabase API."""
if dt is None:
return None
return dt.isoformat()
@router.post("/feed/checkpoint")
def upsert_feed_checkpoint(
payload: list[FeedWaitingTimeIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_CHECKPOINT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []
supabase_rows = []
for item in payload:
rows.append(
{
"id": item.id,
"vn": item.vn,
"txn": item.txn,
"hn": item.hn,
"name": item.name,
"doctor_code": item.doctor_code,
"doctor_name": item.doctor_name,
"location_code": item.location_code,
"location_name": item.location_name,
"step_name": item.step_name,
"time": _to_tz(item.time),
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)),
}
)
supabase_row = {
"vn": item.vn,
"txn": item.txn,
"hn": item.hn,
"name": item.name,
"doctor_code": item.doctor_code,
"doctor_name": item.doctor_name,
"location_code": item.location_code,
"location_name": item.location_name,
"step_name": item.step_name,
"time": _to_tz(item.time).isoformat(),
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)).isoformat(),
}
# if item.id is None:
# del supabase_row[-1]["id"]
supabase_rows.append(supabase_row)
stmt = insert(RawWaitingTime).values(rows)
update_cols = {
"vn": stmt.excluded.vn,
"txn": stmt.excluded.txn,
"hn": stmt.excluded.hn,
"name": stmt.excluded.name,
"doctor_code": stmt.excluded.doctor_code,
"doctor_name": stmt.excluded.doctor_name,
"location_code": stmt.excluded.location_code,
"location_name": stmt.excluded.location_name,
"step_name": stmt.excluded.step_name,
"time": stmt.excluded.time,
"updated_at": stmt.excluded.updated_at,
}
stmt = stmt.on_conflict_do_update(index_elements=[RawWaitingTime.id], set_=update_cols)
result = db.execute(stmt)
db.commit()
# Send data to Supabase via API call
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} records to Supabase API")
supabase_result = upsert_to_supabase_sync(
table="raw_waiting_time",
data=supabase_rows
)
logger.info(f"Successfully sent data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"upserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}
#return {"upserted": len(rows), "rowcount": result.rowcount}
@router.post("/feed/old-checkpoint")
def upsert_opd_checkpoint(
payload: list[FeedCheckpointIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_OLD_CHECKPOINT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []
supabase_rows = []
for item in payload:
# Prepare data for local database
row = {
"id": item.id,
"hn": item.hn,
"vn": item.vn,
"location": item.location,
"type": item.type,
"timestamp_in": _to_tz(item.timestamp_in),
"timestamp_out": _to_tz(item.timestamp_out),
"waiting_time": item.waiting_time,
"bu": item.bu,
}
if item.id is None:
del row["id"]
rows.append(row)
# Prepare data for Supabase API (convert datetime to ISO string)
supabase_row = {
"id": item.id,
"hn": item.hn,
"vn": item.vn,
"location": item.location,
"type": item.type,
"timestamp_in": _to_iso(_to_tz(item.timestamp_in)),
"timestamp_out": _to_iso(_to_tz(item.timestamp_out)),
"waiting_time": item.waiting_time,
"bu": item.bu,
}
if item.id is None:
del supabase_row["id"]
supabase_rows.append(supabase_row)
# Insert/update to local database
stmt = insert(RawOpdCheckpoint).values(rows)
update_cols = {
"id": stmt.excluded.id,
"type": stmt.excluded.type,
"timestamp_in": stmt.excluded.timestamp_in,
"timestamp_out": stmt.excluded.timestamp_out,
"waiting_time": stmt.excluded.waiting_time,
"bu": stmt.excluded.bu,
}
stmt = stmt.on_conflict_do_update(
index_elements=[RawOpdCheckpoint.hn, RawOpdCheckpoint.vn, RawOpdCheckpoint.location],
set_=update_cols,
)
result = db.execute(stmt)
db.commit()
# Send data to Supabase via API call
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} records to Supabase API")
supabase_result = upsert_to_supabase_sync(
table="raw_opd_checkpoint",
data=supabase_rows,
on_conflict="hn,vn,location",
)
logger.info(f"Successfully sent data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"upserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}
@router.post("/feed/patient-appointment")
def upsert_patient_appointment(
payload: list[PatientAppointmentIn],
_: Annotated[object, Depends(require_permission(PERM_FEED_PATIENT_APPOINTMENT_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = []
supabase_rows = []
for item in payload:
# Prepare data for local database
row = {
"hn": item.hn,
"txn": item.txn,
"date": item.date, #+' 0:00:00'
"time": _to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' '+item.time,'%Y-%m-%d %H:%M:%S')),
"doctor_code": item.doctor_code,
"period": item.period,
"appointment_type": item.appointment_type,
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)),
}
rows.append(row)
# Prepare data for Supabase API (convert datetime to ISO string)
supabase_row = {
"hn": item.hn,
"txn": item.txn,
"date": _to_iso(_to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' 00:00:00+07:00','%Y-%m-%d %H:%M:%S%z'))),
"time": _to_iso(_to_tz(datetime.strptime(item.date.strftime("%Y-%m-%d")+' '+item.time+'+07:00','%Y-%m-%d %H:%M:%S%z'))),
"doctor_code": item.doctor_code,
"period": item.period,
"appointment_type": item.appointment_type,
"updated_at": datetime.now(ZoneInfo(settings.TIMEZONE)).isoformat(),
}
supabase_rows.append(supabase_row)
# Insert/update to local database
stmt = insert(PatientAppointment).values(rows)
update_cols = {
"txn": stmt.excluded.txn,
"doctor_code": stmt.excluded.doctor_code,
"period": stmt.excluded.period,
"appointment_type": stmt.excluded.appointment_type,
"updated_at": stmt.excluded.updated_at,
}
stmt = stmt.on_conflict_do_update(
index_elements=[PatientAppointment.hn, PatientAppointment.date, PatientAppointment.time],
set_=update_cols,
)
result = db.execute(stmt)
db.commit()
# Send data to Supabase via API call
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} patient appointment records to Supabase API")
supabase_result = upsert_to_supabase_sync(
table="patient_appointment",
data=supabase_rows,
on_conflict="hn,date,time",
)
logger.info(f"Successfully sent patient appointment data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send patient appointment data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending patient appointment data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"upserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}
@router.post("/voc-data")
def insert_voc_data(
payload: list[VocDataIn],
_: Annotated[object, Depends(require_permission(PERM_VOC_DATA_WRITE))],
db: Annotated[Session, Depends(get_db)],
):
rows = [r.model_dump() for r in payload]
stmt = insert(RawVocData).values(rows)
result = db.execute(stmt)
db.commit()
supabase_rows = [{**r, "date": r["date"].isoformat()} for r in rows]
supabase_result = None
supabase_error = None
try:
logger.info(f"Sending {len(supabase_rows)} VOC records to Supabase API")
supabase_result = upsert_to_supabase_sync(table="raw_voc_data", data=supabase_rows)
logger.info(f"Successfully sent VOC data to Supabase: {supabase_result.get('status_code')}")
except SupabaseAPIError as e:
logger.error(f"Failed to send VOC data to Supabase: {str(e)}")
supabase_error = str(e)
except Exception as e:
logger.error(f"Unexpected error sending VOC data to Supabase: {str(e)}")
supabase_error = f"Unexpected error: {str(e)}"
return {
"inserted": len(rows),
"rowcount": result.rowcount,
"supabase": {
"success": supabase_result is not None,
"result": supabase_result,
"error": supabase_error,
},
}