update configuration docker setup for data platform

This commit is contained in:
jigoong
2026-05-07 17:57:42 +07:00
parent ce949dcc8f
commit 1dba772e62
53 changed files with 6732 additions and 24 deletions

View File

@@ -9,9 +9,9 @@ from fastapi import APIRouter, Depends
from sqlalchemy.dialects.postgresql import insert
from sqlalchemy.orm import Session
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn
from app.api.v1.schemas import FeedCheckpointIn, FeedWaitingTimeIn, PatientAppointmentIn
from app.core.config import settings
from app.db.models import RawOpdCheckpoint, RawWaitingTime
from app.db.models import RawOpdCheckpoint, RawWaitingTime, PatientAppointment
from app.security.dependencies import get_db, require_permission
from app.utils.supabase_client import SupabaseAPIError, upsert_to_supabase_sync
@@ -21,6 +21,7 @@ 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"
def _to_tz(dt):
@@ -220,3 +221,86 @@ def upsert_opd_checkpoint(
"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,
},
}

View File

@@ -1,4 +1,4 @@
from datetime import datetime
from datetime import datetime, time, date
from pydantic import BaseModel
@@ -27,3 +27,13 @@ class FeedCheckpointIn(BaseModel):
timestamp_out: datetime | None = None
waiting_time: int | None = None
bu: str | None = None
class PatientAppointmentIn(BaseModel):
hn: str
txn: int | None = None
date: date
time: str
doctor_code: str | None = None
period: str | None = None
appointment_type: str | None = None

View File

@@ -33,5 +33,20 @@ class Settings(BaseSettings):
API_KEY_ENC_SECRET: str | None = None
# Debug settings
DEBUG_AUTH: bool = False # Set to True to enable detailed authentication logging
# Keycloak Authentication (for web pages only)
KEYCLOAK_SERVER_URL: str = "http://keycloak:8080"
KEYCLOAK_REALM: str = "master"
KEYCLOAK_CLIENT_ID: str = "apiservice"
KEYCLOAK_CLIENT_SECRET: str = ""
KEYCLOAK_REDIRECT_URI: str = "http://localhost:8040/apiservice/auth/callback"
# Airflow Integration
AIRFLOW_API_URL: str = "http://airflow-webserver:8080"
AIRFLOW_API_TOKEN: str = ""
AIRFLOW_DAG_ID_FINANCE: str = "process_finance_excel"
settings = Settings()

View File

@@ -1,12 +1,54 @@
from sqlalchemy import text
from sqlalchemy.orm import Session
import logging
from app.db.base import Base
from app.db.engine import engine
from app.models.user import User, Role # Import models to ensure they're registered
logger = logging.getLogger(__name__)
def init_db() -> None:
"""Initialize database schemas and tables"""
with engine.begin() as conn:
# Create schemas
conn.execute(text("CREATE SCHEMA IF NOT EXISTS fastapi"))
conn.execute(text("CREATE SCHEMA IF NOT EXISTS operationbi"))
conn.execute(text("CREATE SCHEMA IF NOT EXISTS rawdata"))
# Create all tables
Base.metadata.create_all(bind=conn)
logger.info("Database schemas and tables created")
# Seed default roles
seed_roles()
def seed_roles() -> None:
"""Seed default roles if they don't exist"""
from app.db.session import SessionLocal
db = SessionLocal()
try:
roles_data = [
{"name": "admin", "description": "Full system access - can manage users and access all features"},
{"name": "operation", "description": "Data management access - can upload and manage data"}
]
for role_data in roles_data:
existing = db.query(Role).filter(Role.name == role_data["name"]).first()
if not existing:
role = Role(**role_data)
db.add(role)
logger.info(f"Created role: {role_data['name']}")
else:
logger.info(f"Role already exists: {role_data['name']}")
db.commit()
logger.info("Role seeding completed")
except Exception as e:
logger.error(f"Error seeding roles: {e}")
db.rollback()
finally:
db.close()

View File

@@ -46,6 +46,25 @@ class RawWaitingTime(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
class PatientAppointment(Base):
__tablename__ = "patient_appointment"
__table_args__ = (
UniqueConstraint("hn", "date", "time", name="uq_patient_appointment_hn_date_time"),
{"schema": "rawdata"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
hn: Mapped[str] = mapped_column(String(50), nullable=False)
txn: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
doctor_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
period: Mapped[str | None] = mapped_column(String(50), nullable=True)
appointment_type: Mapped[str | None] = mapped_column(String(100), nullable=True)
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):
__tablename__ = "api_client"
__table_args__ = {"schema": "fastapi"}

View File

@@ -0,0 +1,27 @@
"""
Database session management
"""
from sqlalchemy.orm import sessionmaker, Session
from app.db.engine import engine
# Create SessionLocal class
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def get_db() -> Session:
"""
Dependency to get database session
Usage:
@app.get("/items")
def read_items(db: Session = Depends(get_db)):
...
Yields:
Database session
"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -12,6 +12,10 @@ import sqladmin
from app.admin import mount_admin
from app.api.v1.routes import router as v1_router
from app.routes.pages import router as pages_router
from app.routes.auth import router as auth_router
from app.routes.admin_users import router as admin_users_router
from app.middleware.auth_middleware import WebAuthenticationMiddleware
from app.core.config import settings
from app.db.init_db import init_db
@@ -80,8 +84,9 @@ async def global_exception_handler(request, exc):
status_code=500,
content={"detail": "Internal server error", "error": str(exc)}
)
# Middleware order is important! They execute in reverse order (LIFO)
# WebAuthenticationMiddleware needs SessionMiddleware, so SessionMiddleware must be added AFTER
app.add_middleware(ForceHTTPSMiddleware)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.add_middleware(ForwardedProtoMiddleware)
app.add_middleware(
CORSMiddleware,
@@ -90,7 +95,17 @@ app.add_middleware(
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(v1_router)
# Add web authentication middleware (protects /, /docs, /data-management/* only)
# API endpoints (/api/v1/*) continue to use API Key authentication
app.add_middleware(WebAuthenticationMiddleware)
# SessionMiddleware must be added AFTER middlewares that use it (due to LIFO execution)
app.add_middleware(SessionMiddleware, secret_key=settings.ADMIN_SECRET_KEY)
app.include_router(v1_router) # API endpoints - use API Key auth
app.include_router(pages_router) # Web pages - use Keycloak auth
app.include_router(auth_router) # Authentication routes
app.include_router(admin_users_router) # Admin user management API
app.mount("/admin/statics", StaticFiles(directory=statics_path), name="admin_statics")
app.mount("/apiservice/admin/statics", StaticFiles(directory=statics_path), name="proxy_admin_statics")
mount_admin(app)

View File

@@ -0,0 +1 @@
# Middleware package

View File

@@ -0,0 +1,169 @@
"""
Authentication middleware for web pages
Protects web UI routes while allowing API endpoints to use API Key auth
"""
from fastapi import Request
from fastapi.responses import RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class WebAuthenticationMiddleware(BaseHTTPMiddleware):
"""
Middleware to enforce Keycloak authentication for web pages
Protected routes (require user login):
- / (landing page)
- /docs (API documentation)
- /data-management/* (data management pages)
Excluded routes (no auth required or use different auth):
- /auth/* (authentication endpoints)
- /api/v1/* (API endpoints - use API Key auth)
- /admin/* (SQLAdmin - has its own auth)
- /apiservice/admin/statics/* (admin static files)
- /admin/statics/* (admin static files)
"""
# Routes that require user authentication
PROTECTED_PATHS = [
"/",
"/docs",
"/redoc",
"/openapi.json",
"/data-management"
]
# Routes that are excluded from user authentication
EXCLUDED_PATHS = [
"/auth", # Authentication endpoints
"/api/v1", # API endpoints (use API Key)
"/admin", # SQLAdmin (has own auth)
]
async def dispatch(self, request: Request, call_next):
"""Process request and check authentication if needed"""
# Get the path without root_path prefix
path = request.url.path
# Remove root_path prefix if it exists
if settings.ROOT_PATH and path.startswith(settings.ROOT_PATH):
path = path[len(settings.ROOT_PATH):]
# Ensure path starts with /
if not path.startswith("/"):
path = "/" + path
# Check if path is excluded from authentication
is_excluded = any(path.startswith(excluded) for excluded in self.EXCLUDED_PATHS)
if is_excluded:
# Skip authentication for excluded paths (including /api/v1/*)
return await call_next(request)
# Check if path requires authentication
requires_auth = any(
path == protected or path.startswith(protected + "/")
for protected in self.PROTECTED_PATHS
)
if requires_auth:
# Check if user is authenticated
user = request.session.get("user")
if not user:
# User not authenticated - redirect to login
# Preserve the original path for redirect after login
original_path = request.url.path
# Build login URL with redirect
login_url = f"{settings.ROOT_PATH}/auth/login?redirect_to={original_path}"
logger.info(f"Unauthenticated access to {original_path}, redirecting to login")
return RedirectResponse(url=login_url, status_code=302)
# Role-based access control for specific paths
user_roles = user.get("roles", [])
# /data-management/* requires 'operation' or 'admin' role
if path.startswith("/data-management"):
if not any(role in user_roles for role in ["admin", "operation"]):
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access {path}"
)
# Return 403 Forbidden page
from fastapi.responses import HTMLResponse
return HTMLResponse(
content=f"""
<!DOCTYPE html>
<html>
<head>
<title>Access Denied</title>
<style>
body {{
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
}}
.container {{
background: white;
padding: 60px;
border-radius: 20px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
max-width: 500px;
}}
h1 {{ color: #ff6b6b; font-size: 48px; margin-bottom: 20px; }}
p {{ color: #666; font-size: 18px; margin: 15px 0; }}
.role-info {{
background: #f8f9fa;
padding: 20px;
border-radius: 10px;
margin: 20px 0;
}}
.required {{ color: #ff6b6b; font-weight: bold; }}
.your-roles {{ color: #667eea; font-weight: bold; }}
a {{
display: inline-block;
margin-top: 30px;
padding: 12px 30px;
background: #667eea;
color: white;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
transition: all 0.3s;
}}
a:hover {{ background: #5568d3; transform: translateY(-2px); }}
</style>
</head>
<body>
<div class="container">
<h1>🚫</h1>
<h2>Access Denied</h2>
<p>You don't have permission to access this page.</p>
<div class="role-info">
<p>Required role: <span class="required">operation</span> or <span class="required">admin</span></p>
<p>Your roles: <span class="your-roles">{', '.join(user_roles) if user_roles else 'None'}</span></p>
</div>
<p>Please contact your administrator if you need access.</p>
<a href="{settings.ROOT_PATH}/">← Go to Home</a>
</div>
</body>
</html>
""",
status_code=403
)
# Continue with request
return await call_next(request)

View File

@@ -0,0 +1,7 @@
"""
Database models package
"""
from app.models.user import User, Role
from app.models.upload import UploadHistory
__all__ = ["User", "Role", "UploadHistory"]

View File

@@ -0,0 +1,33 @@
"""
Upload History model for tracking file uploads
"""
from sqlalchemy import Column, Integer, String, DateTime, Text
from sqlalchemy.sql import func
from app.db.base import Base
class UploadHistory(Base):
"""
Upload history tracking
Stores information about uploaded files and their processing status
"""
__tablename__ = "upload_history"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
upload_id = Column(String, unique=True, index=True, nullable=False)
filename = Column(String, nullable=False)
filepath = Column(String, nullable=False)
description = Column(Text)
status = Column(String, default="pending")
job_id = Column(String)
logs = Column(Text)
uploaded_by = Column(String)
uploaded_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
airflow_dag_run_id = Column(String)
airflow_state = Column(String)
processing_started_at = Column(DateTime(timezone=True))
processing_completed_at = Column(DateTime(timezone=True))
error_message = Column(Text)

View File

@@ -0,0 +1,55 @@
"""
User and Role models for local user management
Note: This is separate from Keycloak users - used for tracking and audit
"""
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Table, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db.base import Base
# Association table for many-to-many relationship
user_roles = Table(
'user_roles',
Base.metadata,
Column('user_id', Integer, ForeignKey('fastapi.users.id'), primary_key=True),
Column('role_id', Integer, ForeignKey('fastapi.roles.id'), primary_key=True),
schema='fastapi'
)
class User(Base):
"""
Local user record (synced from Keycloak)
Used for tracking, audit, and local permissions
"""
__tablename__ = "users"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
keycloak_id = Column(String, unique=True, index=True, nullable=False) # Keycloak sub
username = Column(String, unique=True, index=True, nullable=False)
email = Column(String, unique=True, index=True)
full_name = Column(String)
is_active = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
last_login = Column(DateTime(timezone=True))
# Relationships
roles = relationship("Role", secondary=user_roles, back_populates="users")
class Role(Base):
"""
Roles (synced from Keycloak)
"""
__tablename__ = "roles"
__table_args__ = {'schema': 'fastapi'}
id = Column(Integer, primary_key=True, index=True)
name = Column(String, unique=True, nullable=False, index=True) # admin, operation
description = Column(String)
created_at = Column(DateTime(timezone=True), server_default=func.now())
# Relationships
users = relationship("User", secondary=user_roles, back_populates="roles")

View File

@@ -0,0 +1 @@
# Routes package

View File

@@ -0,0 +1,156 @@
"""
User and Role management endpoints (Admin only)
Note: This manages local user records synced from Keycloak
API endpoints (/api/v1/*) are NOT affected and continue using API Key authentication
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from pydantic import BaseModel
from datetime import datetime
from app.db.session import get_db
from app.models.user import User, Role
from app.security.permissions import require_role, Roles
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/users", tags=["admin-users"])
# Pydantic schemas
class RoleSchema(BaseModel):
id: int
name: str
description: str | None = None
class Config:
from_attributes = True
class UserSchema(BaseModel):
id: int
keycloak_id: str
username: str
email: str | None = None
full_name: str | None = None
is_active: bool
roles: List[RoleSchema] = []
last_login: datetime | None = None
created_at: datetime
class Config:
from_attributes = True
class UserCreateSchema(BaseModel):
keycloak_id: str
username: str
email: str | None = None
full_name: str | None = None
class UserUpdateSchema(BaseModel):
email: str | None = None
full_name: str | None = None
is_active: bool | None = None
role_ids: List[int] | None = None
@router.get("/", response_model=List[UserSchema])
async def list_users(
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""List all users (Admin only)"""
users = db.query(User).all()
return users
@router.get("/{user_id}", response_model=UserSchema)
async def get_user(
user_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Get user by ID (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return user
@router.post("/", response_model=UserSchema)
async def create_user(
user_data: UserCreateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Create new user record (Admin only)"""
# Check if user already exists
existing = db.query(User).filter(User.keycloak_id == user_data.keycloak_id).first()
if existing:
raise HTTPException(status_code=400, detail="User already exists")
user = User(**user_data.dict())
db.add(user)
db.commit()
db.refresh(user)
return user
@router.put("/{user_id}", response_model=UserSchema)
async def update_user(
user_id: int,
user_data: UserUpdateSchema,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Update user (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Update fields
if user_data.email is not None:
user.email = user_data.email
if user_data.full_name is not None:
user.full_name = user_data.full_name
if user_data.is_active is not None:
user.is_active = user_data.is_active
# Update roles
if user_data.role_ids is not None:
roles = db.query(Role).filter(Role.id.in_(user_data.role_ids)).all()
user.roles = roles
db.commit()
db.refresh(user)
return user
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""Delete user (Admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}
@router.get("/roles/", response_model=List[RoleSchema])
async def list_roles(
db: Session = Depends(get_db),
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""List all roles (Admin only)"""
roles = db.query(Role).all()
return roles

View File

@@ -0,0 +1,252 @@
"""
Authentication routes for Keycloak web login
Note: These routes are ONLY for web UI authentication
API endpoints use API Key authentication separately
"""
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import RedirectResponse
from app.security.keycloak_auth import (
get_keycloak_client,
get_login_url,
get_logout_url,
get_current_user
)
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["authentication"])
@router.get("/login")
async def login(request: Request, redirect_to: str = Query(default="/")):
"""
Redirect to Keycloak login page
Args:
redirect_to: Path to redirect after successful login
"""
# Check if already logged in
user = get_current_user(request)
if user:
return RedirectResponse(url=redirect_to)
# Generate Keycloak login URL
login_url = get_login_url(redirect_to)
return RedirectResponse(url=login_url)
@router.get("/callback")
async def auth_callback(
request: Request,
code: str = Query(...),
state: str = Query(default="/")
):
"""
Handle Keycloak callback after login
Args:
code: Authorization code from Keycloak
state: Original redirect path
"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("AUTHENTICATION CALLBACK RECEIVED")
logger.info(f"Authorization code: {code[:20]}...{code[-20:] if len(code) > 40 else code}")
logger.info(f"State (redirect_to): {state}")
logger.info(f"Request URL: {request.url}")
logger.info(f"Request headers: {dict(request.headers)}")
keycloak_client = get_keycloak_client()
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("EXCHANGING CODE FOR TOKENS")
logger.info(f"Grant type: authorization_code")
logger.info(f"Code: {code[:20]}...")
logger.info(f"Redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
# Exchange authorization code for tokens
token_response = keycloak_client.token(
grant_type="authorization_code",
code=code,
redirect_uri=settings.KEYCLOAK_REDIRECT_URI
)
if settings.DEBUG_AUTH:
logger.info("TOKEN RESPONSE RECEIVED")
logger.info(f"Access token: {'*' * 20}...{token_response.get('access_token', '')[-20:] if token_response.get('access_token') else 'NONE'}")
logger.info(f"Refresh token: {'Present' if token_response.get('refresh_token') else 'None'}")
logger.info(f"Token type: {token_response.get('token_type', 'N/A')}")
logger.info(f"Expires in: {token_response.get('expires_in', 'N/A')} seconds")
# Get user information from token
access_token = token_response.get("access_token")
if not access_token:
logger.error("No access token in response!")
if settings.DEBUG_AUTH:
logger.error(f"Full token response: {token_response}")
raise HTTPException(
status_code=400,
detail="No access token received from Keycloak"
)
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("FETCHING USER INFO")
from jose import jwt
userinfo = jwt.decode(access_token, key="", options={"verify_signature": False, "verify_aud": False})
logger.info(f"Decoded access_token: {userinfo.get('preferred_username')}")
# # 2. ดึง id_token ออกมา (ตัวนี้คือหัวใจของ OIDC)
# id_token = token_response.get("id_token")
# if not id_token:
# logger.error("No id_token in response!")
# if settings.DEBUG_AUTH:
# logger.error(f"Full token response: {token_response}")
# raise HTTPException(
# status_code=400,
# detail="No id_token received from Keycloak"
# )
# # 3. Decode id_token เพื่อเอาข้อมูล User (ไม่ต้องใช้ Key เพราะเราเชื่อถือ Connection นี้)
# userinfo = jwt.decode(
# id_token,
# key="",
# options={"verify_signature": False, "verify_aud": False}
# )
#userinfo = keycloak_client.userinfo(access_token)
# Extract roles from token
roles = []
# 1. Realm roles (roles ระดับ realm)
if "realm_access" in userinfo and "roles" in userinfo["realm_access"]:
roles.extend(userinfo["realm_access"]["roles"])
# 2. Client roles (roles เฉพาะ client apiservice)
if "resource_access" in userinfo and settings.KEYCLOAK_CLIENT_ID in userinfo["resource_access"]:
client_roles = userinfo["resource_access"][settings.KEYCLOAK_CLIENT_ID].get("roles", [])
roles.extend(client_roles)
# Filter to only include our application roles
user_roles = [r for r in roles if r in ["admin", "operation"]]
if settings.DEBUG_AUTH:
logger.info("USER INFO RECEIVED")
logger.info(f"Username: {userinfo.get('preferred_username')}")
logger.info(f"Email: {userinfo.get('email')}")
logger.info(f"Name: {userinfo.get('name')}")
logger.info(f"Sub (User ID): {userinfo.get('sub')}")
logger.info(f"All roles from token: {roles}")
logger.info(f"Filtered user roles: {user_roles}")
logger.info(f"Full userinfo keys: {list(userinfo.keys())}")
# Store user info, roles, and tokens in session
user_session_data = {
"username": userinfo.get("preferred_username"),
"email": userinfo.get("email"),
"name": userinfo.get("name", userinfo.get("preferred_username")),
"sub": userinfo.get("sub"), # User ID
"roles": user_roles, # User roles
"access_token": access_token,
"refresh_token": token_response.get("refresh_token")
}
request.session["user"] = user_session_data
if settings.DEBUG_AUTH:
logger.info("-" * 80)
logger.info("SESSION UPDATED")
logger.info(f"Session user data: {dict((k, v) for k, v in user_session_data.items() if k not in ['access_token', 'refresh_token'])}")
logger.info(f"User {userinfo.get('preferred_username')} logged in successfully")
# Redirect to original destination
redirect_url = state if state else "/"
# Ensure redirect URL starts with root_path if set
if settings.ROOT_PATH and not redirect_url.startswith(settings.ROOT_PATH):
redirect_url = f"{settings.ROOT_PATH}{redirect_url}"
if settings.DEBUG_AUTH:
logger.info(f"Redirecting to: {redirect_url}")
logger.info("=" * 80)
return RedirectResponse(url=redirect_url, status_code=302)
except Exception as e:
logger.error(f"Authentication callback failed: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error("FULL TRACEBACK:")
logger.error(traceback.format_exc())
logger.error("=" * 80)
raise HTTPException(
status_code=400,
detail=f"Authentication failed: {str(e)}"
)
@router.get("/logout")
async def logout(request: Request):
"""
Logout user and clear session
Redirects to Keycloak logout page
"""
user = get_current_user(request)
# Clear session
request.session.clear()
if user:
logger.info(f"User {user.get('username')} logged out")
# Get Keycloak logout URL
redirect_uri = f"{settings.ROOT_PATH}/" if settings.ROOT_PATH else "/"
logout_url = get_logout_url(redirect_uri)
return RedirectResponse(url=logout_url)
@router.get("/user")
async def get_user_info(request: Request):
"""
Get current authenticated user information
Returns 401 if not authenticated
"""
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=401,
detail="Not authenticated"
)
# Return user info without sensitive tokens
return {
"username": user.get("username"),
"email": user.get("email"),
"name": user.get("name"),
"sub": user.get("sub")
}
@router.get("/status")
async def auth_status(request: Request):
"""
Check authentication status
Returns whether user is logged in
"""
user = get_current_user(request)
return {
"authenticated": user is not None,
"user": {
"username": user.get("username"),
"name": user.get("name")
} if user else None
}

View File

@@ -0,0 +1,305 @@
"""
Web page routes for the application
"""
import os
from datetime import datetime
from pathlib import Path
from typing import List
from fastapi import APIRouter, Request, UploadFile, File, Form, HTTPException, Depends
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
from sqlalchemy.orm import Session
import asyncio
import logging
from app.core.config import settings
from app.security.permissions import require_role, Roles
from app.db.session import get_db
from app.models.upload import UploadHistory
from app.services.airflow_client import airflow_client
logger = logging.getLogger(__name__)
router = APIRouter()
# Setup templates
templates_dir = Path(__file__).parent.parent / "templates"
templates = Jinja2Templates(directory=str(templates_dir))
# Upload directory
UPLOAD_DIR = Path("/data/uploads")
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
class UploadRecordSchema(BaseModel):
id: int
upload_id: str
filename: str
filepath: str
description: str | None = None
status: str
job_id: str | None = None
logs: str | None = None
uploaded_by: str | None = None
uploaded_at: datetime
updated_at: datetime | None = None
class Config:
from_attributes = True
@router.get("/", response_class=HTMLResponse)
async def index(request: Request):
"""Landing page with navigation menu"""
user = request.session.get("user")
return templates.TemplateResponse(
"index.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": user
}
)
@router.get("/data-management/finance", response_class=HTMLResponse)
async def finance_page(request: Request):
"""Finance Excel upload page - requires operation or admin role"""
user = request.session.get("user")
return templates.TemplateResponse(
"data_management_finance.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": user
}
)
@router.get("/admin/users", response_class=HTMLResponse)
async def admin_users_page(
request: Request,
current_user: dict = Depends(require_role(Roles.ADMIN))
):
"""User management page - Admin only"""
return templates.TemplateResponse(
"admin_users.html",
{
"request": request,
"root_path": settings.ROOT_PATH,
"user": current_user
}
)
@router.post("/data-management/finance/upload")
async def upload_finance_file(
request: Request,
file: UploadFile = File(...),
description: str = Form(None),
db: Session = Depends(get_db)
):
"""
Handle finance Excel file upload
- Saves file to /data/uploads/
- Stores upload record in database
- Triggers Airflow job (to be implemented)
- Returns upload record
"""
# Validate file type
if not file.filename.endswith(('.xlsx', '.xls')):
raise HTTPException(
status_code=400,
detail="Invalid file type. Only .xlsx and .xls files are allowed."
)
# Generate unique filename
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
safe_filename = file.filename.replace(" ", "_")
unique_filename = f"{timestamp}_{safe_filename}"
filepath = UPLOAD_DIR / unique_filename
# Save file
try:
content = await file.read()
with open(filepath, "wb") as f:
f.write(content)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save file: {str(e)}"
)
# Get username from session
user = request.session.get("user")
username = user.get("username") if user else "anonymous"
# Create upload record in database
upload_id = f"upload_{timestamp}"
upload_record = UploadHistory(
upload_id=upload_id,
filename=file.filename,
filepath=str(filepath),
description=description,
status="pending",
uploaded_by=username
)
db.add(upload_record)
db.commit()
db.refresh(upload_record)
# Trigger Airflow DAG with retry logic
airflow_triggered = False
dag_run_id = None
error_msg = None
max_retries = 3
retry_delay = 10 # seconds
for attempt in range(max_retries):
try:
logger.info(f"Triggering Airflow DAG (attempt {attempt + 1}/{max_retries})")
result = await airflow_client.trigger_finance_dag(
upload_id=upload_id,
filepath=str(filepath),
filename=file.filename,
uploaded_by=username,
description=description
)
dag_run_id = result.get("dag_run_id")
airflow_triggered = True
# Update upload record with Airflow info
upload_record.airflow_dag_run_id = dag_run_id
upload_record.airflow_state = result.get("state", "queued")
upload_record.status = "processing"
db.commit()
logger.info(f"Airflow DAG triggered successfully: {dag_run_id}")
break
except Exception as e:
error_msg = str(e)
logger.error(f"Failed to trigger Airflow (attempt {attempt + 1}/{max_retries}): {error_msg}")
if attempt < max_retries - 1:
logger.info(f"Retrying in {retry_delay} seconds...")
await asyncio.sleep(retry_delay)
else:
logger.error(f"All {max_retries} attempts failed to trigger Airflow")
upload_record.status = "error"
upload_record.error_message = f"Failed to trigger Airflow after {max_retries} attempts: {error_msg}"
db.commit()
return {
"success": True,
"message": f"File '{file.filename}' uploaded successfully",
"upload_id": upload_id,
"filename": unique_filename,
"airflow_triggered": airflow_triggered,
"dag_run_id": dag_run_id,
"error": error_msg if not airflow_triggered else None
}
@router.get("/data-management/finance/uploads")
async def get_uploads(db: Session = Depends(get_db)):
"""Get list of all uploads with their status"""
uploads = db.query(UploadHistory).order_by(UploadHistory.uploaded_at.desc()).all()
# Convert to dict for JSON response
return [
{
"id": upload.upload_id,
"filename": upload.filename,
"filepath": upload.filepath,
"uploaded_at": upload.uploaded_at.isoformat(),
"description": upload.description,
"status": upload.status,
"job_id": upload.job_id,
"logs": upload.logs,
"uploaded_by": upload.uploaded_by,
"airflow_dag_run_id": upload.airflow_dag_run_id,
"airflow_state": upload.airflow_state,
"processing_started_at": upload.processing_started_at.isoformat() if upload.processing_started_at else None,
"processing_completed_at": upload.processing_completed_at.isoformat() if upload.processing_completed_at else None,
"error_message": upload.error_message
}
for upload in uploads
]
@router.get("/data-management/finance/uploads/{upload_id}")
async def get_upload_status(upload_id: str, db: Session = Depends(get_db)):
"""Get status of a specific upload"""
upload = db.query(UploadHistory).filter(UploadHistory.upload_id == upload_id).first()
if not upload:
raise HTTPException(status_code=404, detail="Upload not found")
return {
"id": upload.upload_id,
"filename": upload.filename,
"filepath": upload.filepath,
"uploaded_at": upload.uploaded_at.isoformat(),
"description": upload.description,
"status": upload.status,
"job_id": upload.job_id,
"logs": upload.logs,
"uploaded_by": upload.uploaded_by,
"airflow_dag_run_id": upload.airflow_dag_run_id,
"airflow_state": upload.airflow_state,
"processing_started_at": upload.processing_started_at.isoformat() if upload.processing_started_at else None,
"processing_completed_at": upload.processing_completed_at.isoformat() if upload.processing_completed_at else None,
"error_message": upload.error_message
}
# Placeholder for Airflow integration
async def trigger_airflow_job(filepath: str, upload_id: str) -> str:
"""
Trigger Airflow DAG to process the uploaded file
Args:
filepath: Path to the uploaded file
upload_id: Unique upload identifier
Returns:
job_id: Airflow job/run ID
This function will be implemented when:
- Airflow DAG ID is provided
- Airflow API endpoint is configured
"""
# TODO: Implement Airflow API call
# Example implementation:
# import httpx
#
# airflow_url = "http://airflow-webserver:8080/api/v1/dags/{dag_id}/dagRuns"
# headers = {"Content-Type": "application/json"}
# auth = ("airflow", "airflow") # Use proper credentials
#
# payload = {
# "conf": {
# "filepath": filepath,
# "upload_id": upload_id
# }
# }
#
# async with httpx.AsyncClient() as client:
# response = await client.post(
# airflow_url,
# json=payload,
# headers=headers,
# auth=auth
# )
# response.raise_for_status()
# result = response.json()
# return result["dag_run_id"]
raise NotImplementedError("Airflow integration pending DAG ID and endpoint")

View File

@@ -0,0 +1,146 @@
"""
Keycloak authentication for web pages
Note: This is ONLY for web UI authentication, NOT for API endpoints
API endpoints use API Key authentication (see app/security/dependencies.py)
"""
from typing import Optional
from fastapi import HTTPException, Request, status
from keycloak import KeycloakOpenID
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
# Initialize Keycloak OpenID client
def get_keycloak_client() -> KeycloakOpenID:
"""Get Keycloak OpenID client instance"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("KEYCLOAK CLIENT INITIALIZATION")
logger.info(f"Server URL: {settings.KEYCLOAK_SERVER_URL}")
logger.info(f"Realm: {settings.KEYCLOAK_REALM}")
logger.info(f"Client ID: {settings.KEYCLOAK_CLIENT_ID}")
logger.info(f"Client Secret: {'*' * len(settings.KEYCLOAK_CLIENT_SECRET) if settings.KEYCLOAK_CLIENT_SECRET else 'NOT SET'}")
logger.info(f"Redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
logger.info("=" * 80)
return KeycloakOpenID(
server_url=settings.KEYCLOAK_SERVER_URL,
client_id=settings.KEYCLOAK_CLIENT_ID,
realm_name=settings.KEYCLOAK_REALM,
client_secret_key=settings.KEYCLOAK_CLIENT_SECRET,
verify=True
)
except Exception as e:
logger.error(f"Failed to initialize Keycloak client: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise
def get_current_user(request: Request) -> Optional[dict]:
"""
Get current authenticated user from session
Returns None if not authenticated
"""
return request.session.get("user")
def require_user(request: Request) -> dict:
"""
Dependency to require authenticated user
Raises 401 if not authenticated
"""
user = get_current_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
return user
def get_login_url(redirect_to: str = "/") -> str:
"""
Generate Keycloak login URL
Args:
redirect_to: Path to redirect after successful login
Returns:
Keycloak authorization URL
"""
try:
if settings.DEBUG_AUTH:
logger.info("=" * 80)
logger.info("GENERATING LOGIN URL")
logger.info(f"Redirect to after login: {redirect_to}")
logger.info(f"Keycloak redirect URI: {settings.KEYCLOAK_REDIRECT_URI}")
keycloak_client = get_keycloak_client()
auth_url = keycloak_client.auth_url(
redirect_uri=settings.KEYCLOAK_REDIRECT_URI,
state=redirect_to
)
if settings.DEBUG_AUTH:
logger.info(f"Generated auth URL: {auth_url}")
logger.info("=" * 80)
return auth_url
except Exception as e:
logger.error(f"Failed to generate login URL: {e}")
if settings.DEBUG_AUTH:
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Authentication service unavailable"
)
async def verify_token(token: str) -> dict:
"""
Verify and decode Keycloak access token
Args:
token: Access token from Keycloak
Returns:
User information from token
"""
try:
keycloak_client = get_keycloak_client()
userinfo = keycloak_client.userinfo(token)
return userinfo
except Exception as e:
logger.error(f"Token verification failed: {e}")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired token"
)
def get_logout_url(redirect_uri: str = None) -> str:
"""
Generate Keycloak logout URL
Args:
redirect_uri: Where to redirect after logout
Returns:
Keycloak logout URL
"""
try:
keycloak_client = get_keycloak_client()
if redirect_uri is None:
redirect_uri = f"{settings.ROOT_PATH}/" if settings.ROOT_PATH else "/"
logout_url = keycloak_client.logout_url(redirect_uri=redirect_uri)
return logout_url
except Exception as e:
logger.error(f"Failed to generate logout URL: {e}")
# Return simple redirect if Keycloak logout fails
return redirect_uri or "/"

View File

@@ -0,0 +1,151 @@
"""
Role-based permission system for web pages
Note: API endpoints (/api/v1/*) use API Key authentication and are not affected by this
"""
from typing import List
from fastapi import HTTPException, Request, status
from app.core.config import settings
import logging
logger = logging.getLogger(__name__)
class Roles:
"""Role constants"""
ADMIN = "admin"
OPERATION = "operation"
def get_user_roles(request: Request) -> List[str]:
"""
Get roles from session
Args:
request: FastAPI request object
Returns:
List of role names
"""
user = request.session.get("user")
if not user:
return []
return user.get("roles", [])
def has_role(request: Request, required_role: str) -> bool:
"""
Check if user has specific role
Args:
request: FastAPI request object
required_role: Role name to check
Returns:
True if user has the role, False otherwise
"""
user_roles = get_user_roles(request)
return required_role in user_roles
def has_any_role(request: Request, required_roles: List[str]) -> bool:
"""
Check if user has any of the required roles
Args:
request: FastAPI request object
required_roles: List of role names
Returns:
True if user has at least one of the roles, False otherwise
"""
user_roles = get_user_roles(request)
return any(role in user_roles for role in required_roles)
def require_role(required_role: str):
"""
Dependency to require specific role
Args:
required_role: Role name required to access the endpoint
Returns:
Dependency function that checks for the role
Raises:
HTTPException: 401 if not authenticated, 403 if missing required role
"""
def check_role(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
user_roles = user.get("roles", [])
if required_role not in user_roles:
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access resource requiring '{required_role}'"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required role: {required_role}"
)
return user
return check_role
def require_any_role(required_roles: List[str]):
"""
Dependency to require any of the specified roles
Args:
required_roles: List of role names, user needs at least one
Returns:
Dependency function that checks for roles
Raises:
HTTPException: 401 if not authenticated, 403 if missing all required roles
"""
def check_roles(request: Request):
user = request.session.get("user")
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required"
)
user_roles = user.get("roles", [])
if not any(role in user_roles for role in required_roles):
logger.warning(
f"Access denied: User {user.get('username')} "
f"(roles: {user_roles}) tried to access resource requiring one of: {required_roles}"
)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Access denied. Required roles: {', '.join(required_roles)}"
)
return user
return check_roles
def is_admin(request: Request) -> bool:
"""
Check if user is admin
Args:
request: FastAPI request object
Returns:
True if user has admin role, False otherwise
"""
return has_role(request, Roles.ADMIN)

View File

@@ -0,0 +1,152 @@
"""
Airflow API Client for triggering DAGs
"""
import httpx
import logging
from typing import Dict, Any, Optional
from datetime import datetime
from app.core.config import settings
logger = logging.getLogger(__name__)
class AirflowClient:
"""Client for interacting with Airflow REST API"""
def __init__(self):
self.api_url = settings.AIRFLOW_API_URL.rstrip('/')
self.api_token = settings.AIRFLOW_API_TOKEN
self.headers = {
"Content-Type": "application/json",
"Accept": "application/json"
}
if self.api_token:
self.headers["Authorization"] = f"Bearer {self.api_token}"
else:
logger.warning("AIRFLOW_API_TOKEN not set - API calls may fail")
async def trigger_dag(
self,
dag_id: str,
conf: Dict[str, Any],
logical_date: Optional[str] = None
) -> Dict[str, Any]:
"""
Trigger an Airflow DAG run
Args:
dag_id: DAG identifier
conf: Configuration to pass to the DAG (JSON serializable dict)
logical_date: Optional logical date for the DAG run (ISO format)
Returns:
Dict containing dag_run_id and other metadata
Raises:
httpx.HTTPError: If the API call fails
"""
url = f"{self.api_url}/api/v1/dags/{dag_id}/dagRuns"
payload = {
"conf": conf
}
if logical_date:
payload["logical_date"] = logical_date
else:
payload["logical_date"] = datetime.utcnow().isoformat() + "Z"
logger.info(f"Triggering Airflow DAG: {dag_id}")
logger.debug(f"Payload: {payload}")
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
url,
json=payload,
headers=self.headers
)
response.raise_for_status()
result = response.json()
logger.info(f"DAG triggered successfully: {result.get('dag_run_id')}")
return result
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error triggering DAG {dag_id}: {e.response.status_code} - {e.response.text}")
raise
except httpx.RequestError as e:
logger.error(f"Request error triggering DAG {dag_id}: {str(e)}")
raise
except Exception as e:
logger.error(f"Unexpected error triggering DAG {dag_id}: {str(e)}")
raise
async def get_dag_run_status(
self,
dag_id: str,
dag_run_id: str
) -> Dict[str, Any]:
"""
Get the status of a DAG run
Args:
dag_id: DAG identifier
dag_run_id: DAG run identifier
Returns:
Dict containing DAG run status and metadata
"""
url = f"{self.api_url}/api/v1/dags/{dag_id}/dagRuns/{dag_run_id}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, headers=self.headers)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"Error getting DAG run status: {str(e)}")
raise
async def trigger_finance_dag(
self,
upload_id: str,
filepath: str,
filename: str,
uploaded_by: str,
description: Optional[str] = None
) -> Dict[str, Any]:
"""
Trigger the finance Excel processing DAG
Args:
upload_id: Unique upload identifier
filepath: Full path to the uploaded file
filename: Original filename
uploaded_by: Username who uploaded the file
description: Optional description
Returns:
Dict containing dag_run_id and other metadata
"""
conf = {
"upload_id": upload_id,
"filepath": filepath,
"filename": filename,
"uploaded_by": uploaded_by,
"description": description or "",
"triggered_at": datetime.utcnow().isoformat()
}
return await self.trigger_dag(
dag_id=settings.AIRFLOW_DAG_ID_FINANCE,
conf=conf
)
# Singleton instance
airflow_client = AirflowClient()

View File

@@ -0,0 +1,360 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>User Management - Admin</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 20px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #e0e0e0;
}
h1 { color: #333; font-size: 32px; }
.user-info {
display: flex;
align-items: center;
gap: 15px;
}
.role-badge {
padding: 5px 15px;
border-radius: 20px;
font-size: 12px;
font-weight: bold;
text-transform: uppercase;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #667eea;
text-decoration: none;
font-weight: 600;
font-size: 16px;
}
.back-link:hover { text-decoration: underline; }
.alert {
padding: 15px 20px;
margin-bottom: 20px;
border-radius: 8px;
font-weight: 500;
}
.alert-success {
background: #51cf66;
color: white;
}
.alert-error {
background: #ff6b6b;
color: white;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.stat-card {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px;
text-align: center;
}
.stat-number {
font-size: 36px;
font-weight: bold;
margin-bottom: 5px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
background: white;
}
th, td {
padding: 15px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
th {
background: #f8f9fa;
font-weight: 600;
color: #495057;
position: sticky;
top: 0;
}
tr:hover { background: #f8f9fa; }
.status-active {
color: #51cf66;
font-weight: 600;
}
.status-inactive {
color: #ff6b6b;
font-weight: 600;
}
.btn {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
transform: translateY(-2px);
}
.btn-danger {
background: #ff6b6b;
color: white;
}
.btn-danger:hover {
background: #ee5a52;
transform: translateY(-2px);
}
.loading {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state {
text-align: center;
padding: 60px;
color: #999;
}
.empty-state-icon {
font-size: 64px;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>👥 User Management</h1>
{% if user %}
<div class="user-info">
<span>{{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="stats" id="statsContainer">
<div class="stat-card">
<div class="stat-number" id="totalUsers">-</div>
<div class="stat-label">Total Users</div>
</div>
<div class="stat-card">
<div class="stat-number" id="activeUsers">-</div>
<div class="stat-label">Active Users</div>
</div>
<div class="stat-card">
<div class="stat-number" id="adminUsers">-</div>
<div class="stat-label">Admins</div>
</div>
<div class="stat-card">
<div class="stat-number" id="operationUsers">-</div>
<div class="stat-label">Operations</div>
</div>
</div>
<div id="tableContainer">
<div class="loading">Loading users...</div>
</div>
</div>
<script>
const rootPath = "{{ root_path }}";
async function loadUsers() {
try {
const response = await fetch(`${rootPath}/admin/users/`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const users = await response.json();
// Update stats
document.getElementById('totalUsers').textContent = users.length;
document.getElementById('activeUsers').textContent = users.filter(u => u.is_active).length;
document.getElementById('adminUsers').textContent = users.filter(u =>
u.roles.some(r => r.name === 'admin')
).length;
document.getElementById('operationUsers').textContent = users.filter(u =>
u.roles.some(r => r.name === 'operation')
).length;
// Render table
const tableContainer = document.getElementById('tableContainer');
if (users.length === 0) {
tableContainer.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">📭</div>
<h3>No users found</h3>
<p>Users will appear here when they log in for the first time.</p>
</div>
`;
return;
}
tableContainer.innerHTML = `
<table>
<thead>
<tr>
<th>Username</th>
<th>Email</th>
<th>Full Name</th>
<th>Roles</th>
<th>Status</th>
<th>Last Login</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
${users.map(user => `
<tr>
<td><strong>${escapeHtml(user.username)}</strong></td>
<td>${user.email ? escapeHtml(user.email) : '-'}</td>
<td>${user.full_name ? escapeHtml(user.full_name) : '-'}</td>
<td>
${user.roles.map(role =>
`<span class="role-badge role-${role.name}">${role.name}</span>`
).join(' ') || '<span style="color: #999;">No roles</span>'}
</td>
<td class="${user.is_active ? 'status-active' : 'status-inactive'}">
${user.is_active ? '✅ Active' : '❌ Inactive'}
</td>
<td>${user.last_login ? formatDate(user.last_login) : 'Never'}</td>
<td>
<button class="btn btn-primary" onclick="editUser(${user.id})">Edit</button>
<button class="btn btn-danger" onclick="deleteUser(${user.id}, '${escapeHtml(user.username)}')">Delete</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
`;
} catch (error) {
console.error('Error loading users:', error);
document.getElementById('tableContainer').innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<h3>Error loading users</h3>
<p>${escapeHtml(error.message)}</p>
</div>
`;
showAlert('Failed to load users: ' + error.message, 'error');
}
}
function showAlert(message, type) {
const alertContainer = document.getElementById('alertContainer');
alertContainer.innerHTML = `
<div class="alert alert-${type}">
${escapeHtml(message)}
</div>
`;
setTimeout(() => alertContainer.innerHTML = '', 5000);
}
function editUser(userId) {
// TODO: Implement edit modal
showAlert('Edit functionality coming soon!', 'success');
}
async function deleteUser(userId, username) {
if (!confirm(`Are you sure you want to delete user "${username}"?`)) return;
try {
const response = await fetch(`${rootPath}/admin/users/${userId}`, {
method: 'DELETE'
});
if (response.ok) {
showAlert(`User "${username}" deleted successfully`, 'success');
loadUsers();
} else {
const error = await response.json();
showAlert('Failed to delete user: ' + error.detail, 'error');
}
} catch (error) {
console.error('Error deleting user:', error);
showAlert('Failed to delete user: ' + error.message, 'error');
}
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Load users on page load
loadUsers();
</script>
</body>
</html>

View File

@@ -0,0 +1,635 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Finance Excel Upload - Sriphat Data Platform</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1000px;
margin: 0 auto;
}
.header {
background: white;
border-radius: 12px;
padding: 25px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
position: relative;
}
.header h1 {
color: #333;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 10px;
}
.header p {
color: #666;
}
.user-info {
position: absolute;
top: 25px;
right: 25px;
display: flex;
align-items: center;
gap: 15px;
background: #f8f9fa;
padding: 8px 16px;
border-radius: 20px;
border: 1px solid #dee2e6;
}
.user-info .username {
font-size: 0.9rem;
color: #495057;
font-weight: 500;
}
.logout-btn {
background: #667eea;
color: white;
padding: 5px 12px;
border-radius: 12px;
text-decoration: none;
font-size: 0.85rem;
transition: background 0.3s ease;
}
.logout-btn:hover {
background: #5568d3;
}
.role-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 2px 4px rgba(255,107,107,0.3);
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
box-shadow: 0 2px 4px rgba(78,205,196,0.3);
}
.back-link {
display: inline-block;
color: #667eea;
text-decoration: none;
margin-bottom: 15px;
font-size: 0.9rem;
}
.back-link:hover {
text-decoration: underline;
}
.upload-section {
background: white;
border-radius: 12px;
padding: 30px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.upload-section h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.upload-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-group label {
color: #333;
font-weight: 500;
}
.file-input-wrapper {
position: relative;
display: inline-block;
width: 100%;
}
.file-input {
display: none;
}
.file-input-label {
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
border: 2px dashed #667eea;
border-radius: 8px;
background: #f8f9fa;
cursor: pointer;
transition: all 0.3s ease;
}
.file-input-label:hover {
background: #e9ecef;
border-color: #764ba2;
}
.file-input-label.has-file {
border-color: #28a745;
background: #d4edda;
}
.file-info {
display: none;
margin-top: 10px;
padding: 10px;
background: #e9ecef;
border-radius: 6px;
font-size: 0.9rem;
}
.file-info.show {
display: block;
}
.btn {
padding: 12px 30px;
border: none;
border-radius: 6px;
font-size: 1rem;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 500;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5568d3;
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
}
.alert {
padding: 15px;
border-radius: 6px;
margin-bottom: 20px;
display: none;
}
.alert.show {
display: block;
}
.alert-success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.alert-info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.uploads-list {
background: white;
border-radius: 12px;
padding: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.uploads-list h2 {
color: #333;
margin-bottom: 20px;
font-size: 1.5rem;
}
.upload-item {
border: 1px solid #dee2e6;
border-radius: 8px;
margin-bottom: 15px;
overflow: hidden;
}
.upload-header {
padding: 15px;
background: #f8f9fa;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
transition: background 0.2s ease;
}
.upload-header:hover {
background: #e9ecef;
}
.upload-info {
flex: 1;
}
.upload-filename {
font-weight: 500;
color: #333;
margin-bottom: 5px;
}
.upload-meta {
font-size: 0.85rem;
color: #666;
}
.upload-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-badge {
padding: 5px 12px;
border-radius: 20px;
font-size: 0.85rem;
font-weight: 500;
}
.status-pending {
background: #fff3cd;
color: #856404;
}
.status-processing {
background: #d1ecf1;
color: #0c5460;
}
.status-success {
background: #d4edda;
color: #155724;
}
.status-error {
background: #f8d7da;
color: #721c24;
}
.expand-icon {
transition: transform 0.3s ease;
}
.expand-icon.expanded {
transform: rotate(180deg);
}
.upload-details {
padding: 15px;
background: white;
border-top: 1px solid #dee2e6;
display: none;
}
.upload-details.show {
display: block;
}
.log-container {
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 6px;
padding: 15px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
max-height: 300px;
overflow-y: auto;
white-space: pre-wrap;
word-wrap: break-word;
}
.log-error {
color: #dc3545;
}
.log-info {
color: #17a2b8;
}
.empty-state {
text-align: center;
padding: 40px;
color: #666;
}
.empty-state .icon {
font-size: 3rem;
margin-bottom: 15px;
opacity: 0.5;
}
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(0,0,0,0.1);
border-radius: 50%;
border-top-color: #667eea;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 768px) {
.container {
padding: 10px;
}
.upload-section, .uploads-list {
padding: 20px;
}
}
</style>
</head>
<body>
<div class="container">
<a href="{{ root_path }}/" class="back-link">← Back to Dashboard</a>
<div class="header">
<h1>💰 Finance Excel Upload</h1>
<p>Upload Excel files for financial data processing</p>
{% if user %}
<div class="user-info">
<span class="username">👤 {{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
<a href="{{ root_path }}/auth/logout" class="logout-btn">Logout</a>
</div>
{% endif %}
</div>
<div id="alertContainer"></div>
<div class="upload-section">
<h2>📤 Upload File</h2>
<form id="uploadForm" class="upload-form" enctype="multipart/form-data">
<div class="form-group">
<label for="file">Select Excel File (.xlsx, .xls)</label>
<div class="file-input-wrapper">
<input type="file" id="file" name="file" class="file-input" accept=".xlsx,.xls" required>
<label for="file" id="fileLabel" class="file-input-label">
<span>📁 Click to select file or drag and drop</span>
</label>
</div>
<div id="fileInfo" class="file-info"></div>
</div>
<div class="form-group">
<label for="description">Description (Optional)</label>
<textarea id="description" name="description" rows="3" style="padding: 10px; border: 1px solid #dee2e6; border-radius: 6px; font-family: inherit;"></textarea>
</div>
<button type="submit" class="btn btn-primary" id="submitBtn">
Upload and Process
</button>
</form>
</div>
<div class="uploads-list">
<h2>📋 Upload History</h2>
<div id="uploadsList">
<div class="empty-state">
<div class="icon">📭</div>
<p>No uploads yet</p>
</div>
</div>
</div>
</div>
<script>
const rootPath = '{{ root_path }}';
const fileInput = document.getElementById('file');
const fileLabel = document.getElementById('fileLabel');
const fileInfo = document.getElementById('fileInfo');
const uploadForm = document.getElementById('uploadForm');
const submitBtn = document.getElementById('submitBtn');
const uploadsList = document.getElementById('uploadsList');
const alertContainer = document.getElementById('alertContainer');
// File input handling
fileInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
fileLabel.classList.add('has-file');
fileLabel.innerHTML = `<span>✅ ${file.name}</span>`;
fileInfo.classList.add('show');
fileInfo.textContent = `File: ${file.name} (${formatFileSize(file.size)})`;
}
});
// Drag and drop
fileLabel.addEventListener('dragover', (e) => {
e.preventDefault();
fileLabel.style.borderColor = '#764ba2';
});
fileLabel.addEventListener('dragleave', () => {
fileLabel.style.borderColor = '#667eea';
});
fileLabel.addEventListener('drop', (e) => {
e.preventDefault();
fileLabel.style.borderColor = '#667eea';
const files = e.dataTransfer.files;
if (files.length > 0) {
fileInput.files = files;
fileInput.dispatchEvent(new Event('change'));
}
});
// Form submission
uploadForm.addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(uploadForm);
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner"></span> Uploading...';
try {
const response = await fetch(`${rootPath}/data-management/finance/upload`, {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
showAlert('success', result.message || 'File uploaded successfully!');
uploadForm.reset();
fileLabel.classList.remove('has-file');
fileLabel.innerHTML = '<span>📁 Click to select file or drag and drop</span>';
fileInfo.classList.remove('show');
// Refresh uploads list
loadUploads();
} else {
showAlert('error', result.detail || 'Upload failed');
}
} catch (error) {
showAlert('error', 'Network error: ' + error.message);
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Upload and Process';
}
});
// Load uploads list
async function loadUploads() {
try {
const response = await fetch(`${rootPath}/data-management/finance/uploads`);
const uploads = await response.json();
if (uploads.length === 0) {
uploadsList.innerHTML = `
<div class="empty-state">
<div class="icon">📭</div>
<p>No uploads yet</p>
</div>
`;
return;
}
uploadsList.innerHTML = uploads.map(upload => `
<div class="upload-item">
<div class="upload-header" onclick="toggleDetails('${upload.id}')">
<div class="upload-info">
<div class="upload-filename">${upload.filename}</div>
<div class="upload-meta">
Uploaded: ${formatDate(upload.uploaded_at)}
${upload.description ? `${upload.description}` : ''}
</div>
</div>
<div class="upload-status">
<span class="status-badge status-${upload.status}">${upload.status.toUpperCase()}</span>
<span class="expand-icon" id="icon-${upload.id}">▼</span>
</div>
</div>
<div class="upload-details" id="details-${upload.id}">
${upload.job_id ? `<p><strong>Job ID:</strong> ${upload.job_id}</p>` : ''}
${upload.logs ? `
<h4>Logs:</h4>
<div class="log-container ${upload.status === 'error' ? 'log-error' : 'log-info'}">
${upload.logs}
</div>
` : '<p>No logs available</p>'}
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load uploads:', error);
}
}
function toggleDetails(id) {
const details = document.getElementById(`details-${id}`);
const icon = document.getElementById(`icon-${id}`);
if (details.classList.contains('show')) {
details.classList.remove('show');
icon.classList.remove('expanded');
} else {
details.classList.add('show');
icon.classList.add('expanded');
}
}
function showAlert(type, message) {
const alertClass = type === 'success' ? 'alert-success' : type === 'error' ? 'alert-error' : 'alert-info';
const alert = document.createElement('div');
alert.className = `alert ${alertClass} show`;
alert.textContent = message;
alertContainer.innerHTML = '';
alertContainer.appendChild(alert);
setTimeout(() => {
alert.classList.remove('show');
}, 5000);
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Load uploads on page load
loadUploads();
// Refresh uploads every 10 seconds
setInterval(loadUploads, 10000);
</script>
</body>
</html>

View File

@@ -0,0 +1,296 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sriphat Data Platform</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
}
.header {
text-align: center;
color: white;
margin-bottom: 40px;
padding: 20px;
position: relative;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.user-info {
position: absolute;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 15px;
background: rgba(255,255,255,0.1);
padding: 10px 20px;
border-radius: 25px;
backdrop-filter: blur(10px);
}
.user-info .username {
font-size: 0.95rem;
font-weight: 500;
}
.logout-btn {
background: rgba(255,255,255,0.2);
color: white;
padding: 6px 15px;
border-radius: 15px;
text-decoration: none;
font-size: 0.9rem;
transition: background 0.3s ease;
}
.logout-btn:hover {
background: rgba(255,255,255,0.3);
}
.role-badge {
padding: 4px 12px;
border-radius: 12px;
font-size: 11px;
font-weight: bold;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.role-admin {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
box-shadow: 0 2px 4px rgba(255,107,107,0.3);
}
.role-operation {
background: linear-gradient(135deg, #4ecdc4 0%, #44a08d 100%);
color: white;
box-shadow: 0 2px 4px rgba(78,205,196,0.3);
}
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.menu-card {
background: white;
border-radius: 12px;
padding: 25px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
transition: transform 0.3s ease, box-shadow 0.3s ease;
cursor: pointer;
text-decoration: none;
color: inherit;
display: block;
}
.menu-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 15px rgba(0,0,0,0.2);
}
.menu-card.has-submenu {
cursor: default;
}
.menu-card .icon {
font-size: 2.5rem;
margin-bottom: 15px;
display: block;
}
.menu-card h3 {
color: #333;
margin-bottom: 10px;
font-size: 1.3rem;
}
.menu-card p {
color: #666;
font-size: 0.9rem;
line-height: 1.5;
}
.submenu {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #eee;
display: none;
}
.submenu.active {
display: block;
}
.submenu-item {
display: block;
padding: 10px 15px;
margin: 5px 0;
background: #f8f9fa;
border-radius: 6px;
color: #495057;
text-decoration: none;
transition: background 0.2s ease;
}
.submenu-item:hover {
background: #e9ecef;
}
.submenu-toggle {
display: inline-block;
margin-left: 10px;
font-size: 0.8rem;
color: #667eea;
cursor: pointer;
}
/* Color themes for different services */
.card-supabase { border-left: 4px solid #3ECF8E; }
.card-api { border-left: 4px solid #009688; }
.card-airflow { border-left: 4px solid #017CEE; }
.card-airbyte { border-left: 4px solid #615EFF; }
.card-data { border-left: 4px solid #FF6B6B; }
.card-dbt { border-left: 4px solid #FF694B; }
.card-superset { border-left: 4px solid #20A7C9; }
@media (max-width: 768px) {
.header h1 {
font-size: 2rem;
}
.menu-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🏥 Sriphat Data Platform</h1>
<p>Integrated Data Management & Analytics Platform</p>
{% if user %}
<div class="user-info">
<span class="username">👤 {{ user.name or user.username }}</span>
{% if user.roles %}
{% for role in user.roles %}
<span class="role-badge role-{{ role }}">{{ role }}</span>
{% endfor %}
{% endif %}
<a href="{{ root_path }}/auth/logout" class="logout-btn">Logout</a>
</div>
{% endif %}
</div>
<div class="menu-grid">
<!-- Supabase -->
<a href="https://ai.sriphat.com/supabase" class="menu-card card-supabase" target="_blank">
<span class="icon">🗄️</span>
<h3>Supabase</h3>
<p>PostgreSQL database with real-time capabilities and REST API</p>
</a>
<!-- API Docs -->
<a href="{{ root_path }}/docs" class="menu-card card-api">
<span class="icon">📚</span>
<h3>API Documentation</h3>
<p>Interactive API documentation and testing interface</p>
</a>
<!-- Airflow -->
<a href="https://ai.sriphat.com/airflow" class="menu-card card-airflow" target="_blank">
<span class="icon">🔄</span>
<h3>Airflow</h3>
<p>Workflow orchestration and data pipeline management</p>
</a>
<!-- Airbyte -->
<a href="https://ai.sriphat.com/airbyte" class="menu-card card-airbyte" target="_blank">
<span class="icon">🔌</span>
<h3>Airbyte</h3>
<p>Data integration and ETL platform</p>
</a>
<!-- Data Management -->
<div class="menu-card card-data has-submenu" onclick="toggleSubmenu(this)">
<span class="icon">📊</span>
<h3>Data Management <span class="submenu-toggle"></span></h3>
<p>Upload and manage data files</p>
<div class="submenu">
<a href="{{ root_path }}/data-management/finance" class="submenu-item">
💰 Finance Excel Upload
</a>
</div>
</div>
<!-- DBT -->
<a href="https://ai.sriphat.com/dbt" class="menu-card card-dbt" target="_blank">
<span class="icon">🔧</span>
<h3>DBT</h3>
<p>Data transformation and modeling</p>
</a>
<!-- Superset -->
<a href="https://ai.sriphat.com/superset" class="menu-card card-superset" target="_blank">
<span class="icon">📈</span>
<h3>Superset</h3>
<p>Business intelligence and data visualization</p>
</a>
<!-- User Management (Admin only) -->
{% if user and user.roles and 'admin' in user.roles %}
<a href="{{ root_path }}/admin/users" class="menu-card card-admin">
<span class="icon">👥</span>
<h3>User Management</h3>
<p>Manage users and roles (Admin only)</p>
</a>
{% endif %}
</div>
</div>
<script>
function toggleSubmenu(card) {
const submenu = card.querySelector('.submenu');
const toggle = card.querySelector('.submenu-toggle');
if (submenu.classList.contains('active')) {
submenu.classList.remove('active');
toggle.textContent = '▼';
} else {
submenu.classList.add('active');
toggle.textContent = '▲';
}
}
</script>
</body>
</html>

View File

@@ -48,7 +48,7 @@ async def upsert_to_supabase(
headers["Prefer"] += f",on_conflict={on_conflict}"
try:
async with httpx.AsyncClient(timeout=30.0) as client:
async with httpx.AsyncClient(timeout=30.0, verify=False) as client:
response = await client.post(url, json=data, headers=headers)
response.raise_for_status()
return {
@@ -102,7 +102,7 @@ def upsert_to_supabase_sync(
headers["Prefer"] += f",on_conflict={on_conflict}"
try:
with httpx.Client(timeout=30.0) as client:
with httpx.Client(timeout=30.0, verify=False) as client:
response = client.post(url, json=data, headers=headers)
response.raise_for_status()
return {