diff --git a/03-apiservice-v0.1/.gitignore b/03-apiservice-v0.1/.gitignore new file mode 100644 index 0000000..8d1428b --- /dev/null +++ b/03-apiservice-v0.1/.gitignore @@ -0,0 +1,10 @@ +.env +__pycache__/ +*.pyc +.venv/ +venv/ +.python-version +.pytest_cache/ +.mypy_cache/ +ruff_cache/ +.windsurf/ diff --git a/03-apiservice-v0.1/Dockerfile b/03-apiservice-v0.1/Dockerfile new file mode 100644 index 0000000..e75b84d --- /dev/null +++ b/03-apiservice-v0.1/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY ./app /app/app + +ENV TZ=Asia/Bangkok + +EXPOSE 8040 + +CMD ["gunicorn","-k","uvicorn.workers.UvicornWorker","app.main:app","--bind","0.0.0.0:8040","--workers","2","--access-logfile","-","--error-logfile","-"] diff --git a/03-apiservice-v0.1/README.md b/03-apiservice-v0.1/README.md new file mode 100644 index 0000000..8ce0ee0 --- /dev/null +++ b/03-apiservice-v0.1/README.md @@ -0,0 +1,13 @@ +# 03-apiservice: Custom FastAPI Service + +## Build & Start +```bash +docker compose --env-file ../.env.global up --build -d +``` + +## Access +Internal only - access via Nginx Proxy Manager at `/apiservice` + +## Admin UI +- Login: http:///apiservice/admin/ +- Generate API Key: POST /apiservice/admin/api-keys/generate diff --git a/03-apiservice-v0.1/app/__init__.py b/03-apiservice-v0.1/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/admin.py b/03-apiservice-v0.1/app/admin.py new file mode 100644 index 0000000..77128fc --- /dev/null +++ b/03-apiservice-v0.1/app/admin.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +from fastapi import HTTPException, Request, status +from sqladmin import Admin, ModelView +from sqladmin.authentication import AuthenticationBackend +from starlette.responses import RedirectResponse +from sqlalchemy.orm import sessionmaker +from wtforms import StringField +from wtforms.validators import Optional + +from app.core.config import settings +from app.db.engine import engine +from app.db.models import ApiClient, ApiKey +from app.security.api_key import generate_api_key, get_prefix, hash_api_key + + +class AdminAuth(AuthenticationBackend): + async def login(self, request: Request) -> bool: + form = await request.form() + username = form.get("username") + password = form.get("password") + + if username == settings.ADMIN_USERNAME and password == settings.ADMIN_PASSWORD: + request.session.update({"admin": True}) + return True + return False + + async def logout(self, request: Request) -> bool: + request.session.clear() + return True + + async def authenticate(self, request: Request) -> bool: + return bool(request.session.get("admin")) + + +class ApiClientAdmin(ModelView, model=ApiClient): + column_list = [ApiClient.id, ApiClient.name, ApiClient.is_active] + + +class ApiKeyAdmin(ModelView, model=ApiKey): + form_excluded_columns = [ApiKey.key_hash, ApiKey.key_prefix, ApiKey.created_at] + + form_extra_fields = { + "plain_key": StringField("Plain Key", validators=[Optional()]), + "permissions_csv": StringField("Permissions (comma)", validators=[Optional()]), + } + + async def on_model_change(self, data: dict, model: ApiKey, is_created: bool, request: Request) -> None: + plain_key = data.get("plain_key") + if plain_key: + model.key_prefix = get_prefix(plain_key) + model.key_hash = hash_api_key(plain_key) + + permissions_csv = data.get("permissions_csv") + if permissions_csv is not None: + perms = [p.strip() for p in permissions_csv.split(",") if p.strip()] + model.permissions = perms + + +def mount_admin(app): + auth_backend = AdminAuth(secret_key=settings.ADMIN_SECRET_KEY) + admin = Admin(app=app, engine=engine, authentication_backend=auth_backend) + + SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + admin.add_view(ApiClientAdmin) + admin.add_view(ApiKeyAdmin) + + @app.get("/admin") + async def _admin_redirect(request: Request): + root_path = request.scope.get("root_path") or "" + return RedirectResponse(url=f"{root_path}/admin/") + + @app.post("/admin/api-keys/generate") + async def _admin_generate_api_key( + request: Request, + client_id: int, + permissions: str = "", + name: str | None = None, + ): + if not request.session.get("admin"): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + + perms = [p.strip() for p in permissions.split(",") if p.strip()] + plain_key = generate_api_key() + + db = SessionLocal() + try: + client = db.get(ApiClient, client_id) + if not client: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Client not found") + + api_key = ApiKey( + client_id=client_id, + name=name, + key_prefix=get_prefix(plain_key), + key_hash=hash_api_key(plain_key), + permissions=perms, + is_active=True, + ) + db.add(api_key) + db.commit() + db.refresh(api_key) + + return {"key_id": api_key.id, "api_key": plain_key, "permissions": perms} + finally: + db.close() diff --git a/03-apiservice-v0.1/app/api/__init__.py b/03-apiservice-v0.1/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/api/v1/__init__.py b/03-apiservice-v0.1/app/api/v1/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/api/v1/routes.py b/03-apiservice-v0.1/app/api/v1/routes.py new file mode 100644 index 0000000..d944984 --- /dev/null +++ b/03-apiservice-v0.1/app/api/v1/routes.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from typing import Annotated +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 +from app.core.config import settings +from app.db.models import RawOpdCheckpoint +from app.security.dependencies import get_db, require_permission + + +router = APIRouter(prefix="/api/v1") + +PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint: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)) + + +@router.post("/feed/checkpoint") +def upsert_feed_checkpoint( + payload: list[FeedCheckpointIn], + _: Annotated[object, Depends(require_permission(PERM_FEED_CHECKPOINT_WRITE))], + db: Annotated[Session, Depends(get_db)], +): + rows = [] + for item in payload: + rows.append( + { + "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, + } + ) + + stmt = insert(RawOpdCheckpoint).values(rows) + update_cols = { + "hn": stmt.excluded.hn, + "vn": stmt.excluded.vn, + "location": stmt.excluded.location, + "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.id], set_=update_cols) + result = db.execute(stmt) + db.commit() + + return {"upserted": len(rows), "rowcount": result.rowcount} diff --git a/03-apiservice-v0.1/app/api/v1/schemas.py b/03-apiservice-v0.1/app/api/v1/schemas.py new file mode 100644 index 0000000..cb67e1d --- /dev/null +++ b/03-apiservice-v0.1/app/api/v1/schemas.py @@ -0,0 +1,15 @@ +from datetime import datetime + +from pydantic import BaseModel + + +class FeedCheckpointIn(BaseModel): + id: int + hn: int + vn: int + location: str + type: str + timestamp_in: datetime + timestamp_out: datetime | None = None + waiting_time: int | None = None + bu: str | None = None diff --git a/03-apiservice-v0.1/app/core/__init__.py b/03-apiservice-v0.1/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/core/config.py b/03-apiservice-v0.1/app/core/config.py new file mode 100644 index 0000000..1e503a0 --- /dev/null +++ b/03-apiservice-v0.1/app/core/config.py @@ -0,0 +1,25 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + APP_NAME: str = "APIsService" + + DB_HOST: str + DB_PORT: int = 5432 + DB_USER: str + DB_PASSWORD: str + DB_NAME: str + DB_SSLMODE: str = "prefer" + + ROOT_PATH: str = "" + + TIMEZONE: str = "Asia/Bangkok" + + ADMIN_SECRET_KEY: str + ADMIN_USERNAME: str + ADMIN_PASSWORD: str + + +settings = Settings() diff --git a/03-apiservice-v0.1/app/db/__init__.py b/03-apiservice-v0.1/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/db/base.py b/03-apiservice-v0.1/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/03-apiservice-v0.1/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/03-apiservice-v0.1/app/db/engine.py b/03-apiservice-v0.1/app/db/engine.py new file mode 100644 index 0000000..62d5465 --- /dev/null +++ b/03-apiservice-v0.1/app/db/engine.py @@ -0,0 +1,21 @@ +from urllib.parse import quote_plus + +from sqlalchemy import create_engine + +from app.core.config import settings + + +def build_db_url() -> str: + user = quote_plus(settings.DB_USER) + password = quote_plus(settings.DB_PASSWORD) + host = settings.DB_HOST + port = settings.DB_PORT + db = quote_plus(settings.DB_NAME) + + return ( + f"postgresql+psycopg://{user}:{password}@{host}:{port}/{db}" + f"?sslmode={quote_plus(settings.DB_SSLMODE)}" + ) + + +engine = create_engine(build_db_url(), pool_pre_ping=True) diff --git a/03-apiservice-v0.1/app/db/init_db.py b/03-apiservice-v0.1/app/db/init_db.py new file mode 100644 index 0000000..3b2887d --- /dev/null +++ b/03-apiservice-v0.1/app/db/init_db.py @@ -0,0 +1,12 @@ +from sqlalchemy import text + +from app.db.base import Base +from app.db.engine import engine + + +def init_db() -> None: + with engine.begin() as conn: + conn.execute(text("CREATE SCHEMA IF NOT EXISTS fastapi")) + conn.execute(text("CREATE SCHEMA IF NOT EXISTS operationbi")) + + Base.metadata.create_all(bind=conn) diff --git a/03-apiservice-v0.1/app/db/models.py b/03-apiservice-v0.1/app/db/models.py new file mode 100644 index 0000000..ddbf497 --- /dev/null +++ b/03-apiservice-v0.1/app/db/models.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class RawOpdCheckpoint(Base): + __tablename__ = "raw_opd_checkpoint" + __table_args__ = {"schema": "operationbi"} + + id: Mapped[int] = mapped_column(BigInteger, primary_key=True) + hn: Mapped[int] = mapped_column(BigInteger, nullable=False) + vn: Mapped[int] = mapped_column(BigInteger, nullable=False) + location: Mapped[str] = mapped_column(Text, nullable=False) + type: Mapped[str] = mapped_column(String(64), nullable=False) + timestamp_in: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + timestamp_out: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + waiting_time: Mapped[int | None] = mapped_column(Integer, nullable=True) + bu: Mapped[str | None] = mapped_column(String(128), nullable=True) + + +class ApiClient(Base): + __tablename__ = "api_client" + __table_args__ = {"schema": "fastapi"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(128), unique=True, nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + api_keys: Mapped[list[ApiKey]] = relationship( + back_populates="client", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + +class ApiKey(Base): + __tablename__ = "api_key" + __table_args__ = {"schema": "fastapi"} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + client_id: Mapped[int] = mapped_column( + ForeignKey("fastapi.api_client.id", ondelete="CASCADE"), nullable=False + ) + name: Mapped[str | None] = mapped_column(String(128), nullable=True) + + key_prefix: Mapped[str] = mapped_column(String(12), nullable=False) + key_hash: Mapped[str] = mapped_column(Text, nullable=False) + + permissions: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now() + ) + + client: Mapped[ApiClient] = relationship(back_populates="api_keys") diff --git a/03-apiservice-v0.1/app/main.py b/03-apiservice-v0.1/app/main.py new file mode 100644 index 0000000..a52d0f2 --- /dev/null +++ b/03-apiservice-v0.1/app/main.py @@ -0,0 +1,21 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from starlette.middleware.sessions import SessionMiddleware + +from app.admin import mount_admin +from app.api.v1.routes import router as v1_router +from app.core.config import settings +from app.db.init_db import init_db + + +@asynccontextmanager +async def lifespan(_: FastAPI): + init_db() + yield + + +app = FastAPI(title=settings.APP_NAME, root_path=settings.ROOT_PATH, lifespan=lifespan) +app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY) +app.include_router(v1_router) +mount_admin(app) diff --git a/03-apiservice-v0.1/app/security/__init__.py b/03-apiservice-v0.1/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/03-apiservice-v0.1/app/security/api_key.py b/03-apiservice-v0.1/app/security/api_key.py new file mode 100644 index 0000000..9a28d98 --- /dev/null +++ b/03-apiservice-v0.1/app/security/api_key.py @@ -0,0 +1,22 @@ +import secrets + +import bcrypt + + +def generate_api_key(prefix_len: int = 8, token_bytes: int = 32) -> str: + prefix = secrets.token_urlsafe(prefix_len)[:prefix_len] + token = secrets.token_urlsafe(token_bytes) + return f"{prefix}.{token}" + + +def get_prefix(api_key: str) -> str: + return api_key.split(".", 1)[0] + + +def hash_api_key(api_key: str) -> str: + hashed = bcrypt.hashpw(api_key.encode("utf-8"), bcrypt.gensalt()) + return hashed.decode("utf-8") + + +def verify_api_key(api_key: str, api_key_hash: str) -> bool: + return bcrypt.checkpw(api_key.encode("utf-8"), api_key_hash.encode("utf-8")) diff --git a/03-apiservice-v0.1/app/security/dependencies.py b/03-apiservice-v0.1/app/security/dependencies.py new file mode 100644 index 0000000..b7bc910 --- /dev/null +++ b/03-apiservice-v0.1/app/security/dependencies.py @@ -0,0 +1,54 @@ +from typing import Annotated + +from fastapi import Depends, HTTPException, Request, status +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker + +from app.db.engine import engine +from app.db.models import ApiKey +from app.security.api_key import get_prefix, verify_api_key + + +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + + +def get_bearer_token(request: Request) -> str: + auth = request.headers.get("authorization") + if not auth: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Authorization") + + parts = auth.split(" ", 1) + if len(parts) != 2 or parts[0].lower() != "bearer" or not parts[1].strip(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Authorization") + + return parts[1].strip() + + +def require_permission(permission: str): + def _dep( + token: Annotated[str, Depends(get_bearer_token)], + db: Annotated[Session, Depends(get_db)], + ) -> ApiKey: + prefix = get_prefix(token) + stmt = select(ApiKey).where(ApiKey.key_prefix == prefix, ApiKey.is_active.is_(True)) + api_key = db.execute(stmt).scalar_one_or_none() + if not api_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + if not verify_api_key(token, api_key.key_hash): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + + if permission not in (api_key.permissions or []): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Permission denied") + + return api_key + + return _dep diff --git a/03-apiservice-v0.1/docker-compose.yml b/03-apiservice-v0.1/docker-compose.yml new file mode 100644 index 0000000..66f77d9 --- /dev/null +++ b/03-apiservice-v0.1/docker-compose.yml @@ -0,0 +1,32 @@ +services: + apiservice: + build: . + container_name: apiservice + env_file: + - ../.env.global + environment: + - TZ=${TZ:-Asia/Bangkok} + - DB_HOST=${DB_HOST} + - DB_PORT=${DB_PORT} + - DB_USER=${DB_USER} + - DB_PASSWORD=${DB_PASSWORD} + - DB_NAME=${DB_NAME} + - DB_SSLMODE=${DB_SSLMODE} + - ROOT_PATH=${ROOT_PATH} + - APP_NAME=${APP_NAME} + - ADMIN_SECRET_KEY=${ADMIN_SECRET_KEY} + - ADMIN_USERNAME=${ADMIN_USERNAME} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + networks: + - shared_data_network + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8040/apiservice/docs"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +networks: + shared_data_network: + external: true diff --git a/03-apiservice-v0.1/requirements.txt b/03-apiservice-v0.1/requirements.txt new file mode 100644 index 0000000..49a79e1 --- /dev/null +++ b/03-apiservice-v0.1/requirements.txt @@ -0,0 +1,14 @@ +fastapi==0.115.8 +uvicorn==0.34.0 +gunicorn==23.0.0 +SQLAlchemy==2.0.38 +psycopg==3.2.5 +pydantic==2.10.6 +pydantic-settings==2.7.1 +sqladmin==0.20.1 +itsdangerous==2.2.0 +bcrypt==4.3.0 +python-multipart==0.0.20 +WTForms +#==3.2.1 + diff --git a/03-apiservice/app/api/v1/routes.py b/03-apiservice/app/api/v1/routes.py index d944984..d0e0ca7 100644 --- a/03-apiservice/app/api/v1/routes.py +++ b/03-apiservice/app/api/v1/routes.py @@ -1,21 +1,22 @@ from __future__ import annotations 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 +from app.api.v1.schemas import FeedWaitingTimeIn from app.core.config import settings -from app.db.models import RawOpdCheckpoint +from app.db.models import RawWaitingTime from app.security.dependencies import get_db, require_permission router = APIRouter(prefix="/api/v1") -PERM_FEED_CHECKPOINT_WRITE = "feed.checkpoint:write" +PERM_FEED_WAITING_TIME_WRITE = "feed.waiting-time:write" def _to_tz(dt): @@ -28,8 +29,8 @@ def _to_tz(dt): @router.post("/feed/checkpoint") def upsert_feed_checkpoint( - payload: list[FeedCheckpointIn], - _: Annotated[object, Depends(require_permission(PERM_FEED_CHECKPOINT_WRITE))], + payload: list[FeedWaitingTimeIn], + _: Annotated[object, Depends(require_permission(PERM_FEED_WAITING_TIME_WRITE))], db: Annotated[Session, Depends(get_db)], ): rows = [] @@ -37,30 +38,36 @@ def upsert_feed_checkpoint( rows.append( { "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, + "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)), } ) - stmt = insert(RawOpdCheckpoint).values(rows) + stmt = insert(RawWaitingTime).values(rows) update_cols = { - "hn": stmt.excluded.hn, "vn": stmt.excluded.vn, - "location": stmt.excluded.location, - "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, + "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=[RawOpdCheckpoint.id], set_=update_cols) + stmt = stmt.on_conflict_do_update(index_elements=[RawWaitingTime.id], set_=update_cols) result = db.execute(stmt) db.commit() diff --git a/03-apiservice/app/api/v1/schemas.py b/03-apiservice/app/api/v1/schemas.py index cb67e1d..76cc5c2 100644 --- a/03-apiservice/app/api/v1/schemas.py +++ b/03-apiservice/app/api/v1/schemas.py @@ -3,13 +3,15 @@ from datetime import datetime from pydantic import BaseModel -class FeedCheckpointIn(BaseModel): +class FeedWaitingTimeIn(BaseModel): id: int - hn: int - vn: int - location: str - type: str - timestamp_in: datetime - timestamp_out: datetime | None = None - waiting_time: int | None = None - bu: str | None = None + vn: int | None = None + txn: int | None = None + hn: str | None = None + name: str | None = None + doctor_code: str | None = None + doctor_name: str | None = None + location_code: str | None = None + location_name: str | None = None + step_name: str | None = None + time: datetime diff --git a/03-apiservice/app/db/models.py b/03-apiservice/app/db/models.py index ddbf497..0f41908 100644 --- a/03-apiservice/app/db/models.py +++ b/03-apiservice/app/db/models.py @@ -9,19 +9,23 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship from app.db.base import Base -class RawOpdCheckpoint(Base): - __tablename__ = "raw_opd_checkpoint" - __table_args__ = {"schema": "operationbi"} +class RawWaitingTime(Base): + __tablename__ = "raw_waiting_time" + __table_args__ = {"schema": "rawdata"} id: Mapped[int] = mapped_column(BigInteger, primary_key=True) - hn: Mapped[int] = mapped_column(BigInteger, nullable=False) - vn: Mapped[int] = mapped_column(BigInteger, nullable=False) - location: Mapped[str] = mapped_column(Text, nullable=False) - type: Mapped[str] = mapped_column(String(64), nullable=False) - timestamp_in: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) - timestamp_out: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) - waiting_time: Mapped[int | None] = mapped_column(Integer, nullable=True) - bu: Mapped[str | None] = mapped_column(String(128), nullable=True) + vn: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + txn: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + hn: Mapped[str | None] = mapped_column(String(50), nullable=True) + name: Mapped[str | None] = mapped_column(Text, nullable=True) + doctor_code: Mapped[str | None] = mapped_column(String(50), nullable=True) + doctor_name: Mapped[str | None] = mapped_column(Text, nullable=True) + location_code: Mapped[str | None] = mapped_column(String(50), nullable=True) + location_name: Mapped[str | None] = mapped_column(Text, nullable=True) + step_name: Mapped[str | None] = mapped_column(Text, nullable=True) + time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) class ApiClient(Base):