feat: Migrate to MySQL and add unified environment configuration

## Database Migration (SQLite → MySQL)
- Add Alembic migration framework
- Add 'tr_' prefix to all tables to avoid conflicts in shared database
- Remove SQLite support, use MySQL exclusively
- Add pymysql driver dependency
- Change ad_token column to Text type for long JWT tokens

## Unified Environment Configuration
- Centralize all hardcoded settings to environment variables
- Backend: Extend Settings class in app/core/config.py
- Frontend: Use Vite environment variables (import.meta.env)
- Docker: Move credentials to environment variables
- Update .env.example files with comprehensive documentation

## Test Organization
- Move root-level test files to tests/ directory:
  - test_chat_room.py → tests/test_chat_room.py
  - test_websocket.py → tests/test_websocket.py
  - test_realtime_implementation.py → tests/test_realtime_implementation.py
- Fix path references in test_realtime_implementation.py

Breaking Changes:
- CORS now requires explicit origins (no more wildcard)
- All database tables renamed with 'tr_' prefix
- SQLite no longer supported

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
egg
2025-12-07 14:15:11 +08:00
parent 1d5d4d447d
commit 92834dbe0e
39 changed files with 1558 additions and 136 deletions

View File

@@ -1,6 +1,9 @@
"""Application configuration loaded from environment variables"""
from pydantic_settings import BaseSettings
from pydantic import field_validator
from functools import lru_cache
from typing import List
import logging
class Settings(BaseSettings):
@@ -14,6 +17,7 @@ class Settings(BaseSettings):
# AD API
AD_API_URL: str
AD_API_TIMEOUT_SECONDS: int = 10 # AD API request timeout
# Session Settings
SESSION_INACTIVITY_DAYS: int = 3
@@ -23,7 +27,23 @@ class Settings(BaseSettings):
# Server
HOST: str = "0.0.0.0"
PORT: int = 8000
DEBUG: bool = True
DEBUG: bool = False # Default to False for security
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
# CORS Configuration
CORS_ORIGINS: str = "http://localhost:3000" # Comma-separated list of allowed origins
# System Admin
SYSTEM_ADMIN_EMAIL: str = "" # System administrator email with special permissions
# Realtime Messaging Settings
MESSAGE_EDIT_TIME_LIMIT_MINUTES: int = 15 # Time limit for editing messages
TYPING_TIMEOUT_SECONDS: int = 3 # Typing indicator timeout
# File Upload Size Limits (in MB)
IMAGE_MAX_SIZE_MB: int = 10
DOCUMENT_MAX_SIZE_MB: int = 20
LOG_MAX_SIZE_MB: int = 5
# MinIO Object Storage
MINIO_ENDPOINT: str = "localhost:9000"
@@ -41,6 +61,41 @@ class Settings(BaseSettings):
REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded
REPORT_STORAGE_PATH: str = "reports" # MinIO path prefix for reports
@field_validator("LOG_LEVEL")
@classmethod
def validate_log_level(cls, v: str) -> str:
"""Validate log level"""
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
v_upper = v.upper()
if v_upper not in valid_levels:
raise ValueError(f"LOG_LEVEL must be one of {valid_levels}")
return v_upper
def get_cors_origins(self) -> List[str]:
"""Parse CORS_ORIGINS into a list"""
if not self.CORS_ORIGINS:
return []
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
def get_image_max_size_bytes(self) -> int:
"""Get image max size in bytes"""
return self.IMAGE_MAX_SIZE_MB * 1024 * 1024
def get_document_max_size_bytes(self) -> int:
"""Get document max size in bytes"""
return self.DOCUMENT_MAX_SIZE_MB * 1024 * 1024
def get_log_max_size_bytes(self) -> int:
"""Get log file max size in bytes"""
return self.LOG_MAX_SIZE_MB * 1024 * 1024
def configure_logging(self) -> None:
"""Configure application logging based on LOG_LEVEL"""
logging.basicConfig(
level=getattr(logging, self.LOG_LEVEL),
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s"
)
class Config:
env_file = ".env"
case_sensitive = True

View File

@@ -1,4 +1,8 @@
"""Database connection and session management"""
"""Database connection and session management
Supports MySQL database with connection pooling.
All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
@@ -6,10 +10,13 @@ from app.core.config import get_settings
settings = get_settings()
# Create engine
# Create engine with MySQL connection pooling
engine = create_engine(
settings.DATABASE_URL,
connect_args={"check_same_thread": False} if "sqlite" in settings.DATABASE_URL else {},
pool_size=5,
max_overflow=10,
pool_pre_ping=True, # Verify connection before using
pool_recycle=3600, # Recycle connections after 1 hour
echo=settings.DEBUG,
)

View File

@@ -9,7 +9,6 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.core.config import get_settings
from app.core.database import engine, Base
from app.modules.auth import router as auth_router
from app.modules.auth.users_router import router as users_router
from app.modules.auth.middleware import auth_middleware
@@ -24,8 +23,8 @@ FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
settings = get_settings()
# Create database tables
Base.metadata.create_all(bind=engine)
# Database tables are managed by Alembic migrations
# Run: alembic upgrade head
# Initialize FastAPI app
app = FastAPI(
@@ -35,10 +34,10 @@ app = FastAPI(
debug=settings.DEBUG,
)
# CORS middleware (adjust for production)
# CORS middleware - origins configured via CORS_ORIGINS environment variable
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # TODO: Restrict in production
allow_origins=settings.get_cors_origins(),
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],

View File

@@ -1,10 +1,12 @@
"""SQLAlchemy models for authentication
資料表結構:
- user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
- users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
- tr_user_sessions: 儲存使用者 session 資料,包含加密密碼用於自動刷新
- tr_users: 永久儲存使用者資訊 (用於報告生成時的姓名解析)
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, Integer, String, DateTime, Index
from sqlalchemy import Column, Integer, String, DateTime, Index, Text
from datetime import datetime
from app.core.database import Base
@@ -12,7 +14,7 @@ from app.core.database import Base
class UserSession(Base):
"""User session model with encrypted password for auto-refresh"""
__tablename__ = "user_sessions"
__tablename__ = "tr_user_sessions"
id = Column(Integer, primary_key=True, index=True)
username = Column(String(255), nullable=False, comment="User email from AD")
@@ -20,7 +22,7 @@ class UserSession(Base):
internal_token = Column(
String(255), unique=True, nullable=False, index=True, comment="Internal session token (UUID)"
)
ad_token = Column(String(500), nullable=False, comment="AD API token")
ad_token = Column(Text, nullable=False, comment="AD API token (JWT)")
encrypted_password = Column(String(500), nullable=False, comment="AES-256 encrypted password")
ad_token_expires_at = Column(DateTime, nullable=False, comment="AD token expiry time")
refresh_attempt_count = Column(
@@ -41,7 +43,7 @@ class User(Base):
- Tracking user metadata (office location, job title)
"""
__tablename__ = "users"
__tablename__ = "tr_users"
user_id = Column(
String(255), primary_key=True, comment="User email address (e.g., ymirliu@panjit.com.tw)"
@@ -64,5 +66,5 @@ class User(Base):
# Indexes
__table_args__ = (
Index("ix_users_display_name", "display_name"),
Index("ix_tr_users_display_name", "display_name"),
)

View File

@@ -18,7 +18,7 @@ class ADAuthService:
def __init__(self):
self.ad_api_url = settings.AD_API_URL
self._client = httpx.AsyncClient(timeout=10.0)
self._client = httpx.AsyncClient(timeout=float(settings.AD_API_TIMEOUT_SECONDS))
async def authenticate(self, username: str, password: str) -> Dict[str, any]:
"""Authenticate user with AD API

View File

@@ -1,9 +1,11 @@
"""SQLAlchemy models for chat room management
Tables:
- incident_rooms: Stores room metadata and configuration
- room_members: User-room associations with roles
- room_templates: Predefined templates for common incident types
- tr_incident_rooms: Stores room metadata and configuration
- tr_room_members: User-room associations with roles
- tr_room_templates: Predefined templates for common incident types
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index
from sqlalchemy.orm import relationship
@@ -46,7 +48,7 @@ class MemberRole(str, enum.Enum):
class IncidentRoom(Base):
"""Incident room model for production incidents"""
__tablename__ = "incident_rooms"
__tablename__ = "tr_incident_rooms"
room_id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
title = Column(String(255), nullable=False)
@@ -80,18 +82,18 @@ class IncidentRoom(Base):
# Indexes for common queries
__table_args__ = (
Index("ix_incident_rooms_status_created", "status", "created_at"),
Index("ix_incident_rooms_created_by", "created_by"),
Index("ix_tr_incident_rooms_status_created", "status", "created_at"),
Index("ix_tr_incident_rooms_created_by", "created_by"),
)
class RoomMember(Base):
"""Room membership model"""
__tablename__ = "room_members"
__tablename__ = "tr_room_members"
id = Column(Integer, primary_key=True, autoincrement=True)
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False) # User email/ID
role = Column(Enum(MemberRole), nullable=False)
@@ -106,16 +108,16 @@ class RoomMember(Base):
# Constraints and indexes
__table_args__ = (
# Ensure unique active membership (where removed_at IS NULL)
UniqueConstraint("room_id", "user_id", "removed_at", name="uq_room_member_active"),
Index("ix_room_members_room_user", "room_id", "user_id"),
Index("ix_room_members_user", "user_id"),
UniqueConstraint("room_id", "user_id", "removed_at", name="uq_tr_room_member_active"),
Index("ix_tr_room_members_room_user", "room_id", "user_id"),
Index("ix_tr_room_members_user", "user_id"),
)
class RoomTemplate(Base):
"""Predefined templates for common incident types"""
__tablename__ = "room_templates"
__tablename__ = "tr_room_templates"
template_id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), unique=True, nullable=False)

View File

@@ -8,13 +8,13 @@ from app.core.database import Base
class RoomFile(Base):
"""File uploaded to an incident room"""
__tablename__ = "room_files"
__tablename__ = "tr_room_files"
# Primary key
file_id = Column(String(36), primary_key=True)
# Foreign key to incident room (CASCADE delete when room is permanently deleted)
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
# File metadata
uploader_id = Column(String(255), nullable=False)
@@ -36,8 +36,8 @@ class RoomFile(Base):
# Indexes
__table_args__ = (
Index("ix_room_files", "room_id", "uploaded_at"),
Index("ix_file_uploader", "uploader_id"),
Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"),
Index("ix_tr_room_files_uploader", "uploader_id"),
)
def __repr__(self):

View File

@@ -4,7 +4,10 @@ from fastapi import UploadFile, HTTPException
from typing import Set
import logging
from app.core.config import get_settings
logger = logging.getLogger(__name__)
settings = get_settings()
# MIME type whitelists
IMAGE_TYPES: Set[str] = {
@@ -22,11 +25,6 @@ LOG_TYPES: Set[str] = {
"text/csv"
}
# File size limits (bytes)
IMAGE_MAX_SIZE = 10 * 1024 * 1024 # 10MB
DOCUMENT_MAX_SIZE = 20 * 1024 * 1024 # 20MB
LOG_MAX_SIZE = 5 * 1024 * 1024 # 5MB
def detect_mime_type(file_data: bytes) -> str:
"""
@@ -118,11 +116,11 @@ def get_file_type_and_limits(mime_type: str) -> tuple[str, int]:
HTTPException if MIME type not recognized
"""
if mime_type in IMAGE_TYPES:
return ("image", IMAGE_MAX_SIZE)
return ("image", settings.get_image_max_size_bytes())
elif mime_type in DOCUMENT_TYPES:
return ("document", DOCUMENT_MAX_SIZE)
return ("document", settings.get_document_max_size_bytes())
elif mime_type in LOG_TYPES:
return ("log", LOG_MAX_SIZE)
return ("log", settings.get_log_max_size_bytes())
else:
raise HTTPException(
status_code=400,

View File

@@ -1,9 +1,11 @@
"""SQLAlchemy models for realtime messaging
Tables:
- messages: Stores all messages sent in incident rooms
- message_reactions: User reactions to messages (emoji)
- message_edit_history: Audit trail for message edits
- tr_messages: Stores all messages sent in incident rooms
- tr_message_reactions: User reactions to messages (emoji)
- tr_message_edit_history: Audit trail for message edits
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index, BigInteger, JSON
from sqlalchemy.orm import relationship
@@ -25,10 +27,10 @@ class MessageType(str, enum.Enum):
class Message(Base):
"""Message model for incident room communications"""
__tablename__ = "messages"
__tablename__ = "tr_messages"
message_id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
room_id = Column(String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
sender_id = Column(String(255), nullable=False) # User email/ID
content = Column(Text, nullable=False)
message_type = Column(Enum(MessageType), default=MessageType.TEXT, nullable=False)
@@ -42,7 +44,6 @@ class Message(Base):
deleted_at = Column(DateTime) # Soft delete timestamp
# Sequence number for FIFO ordering within a room
# Note: Autoincrement doesn't work for non-PK in SQLite, will be set in service layer
sequence_number = Column(BigInteger, nullable=False)
# Relationships
@@ -51,22 +52,19 @@ class Message(Base):
# Indexes for common queries
__table_args__ = (
Index("ix_messages_room_created", "room_id", "created_at"),
Index("ix_messages_room_sequence", "room_id", "sequence_number"),
Index("ix_messages_sender", "sender_id"),
# PostgreSQL full-text search index on content (commented for SQLite compatibility)
# Note: Uncomment when using PostgreSQL with pg_trgm extension enabled
# Index("ix_messages_content_search", "content", postgresql_using='gin', postgresql_ops={'content': 'gin_trgm_ops'}),
Index("ix_tr_messages_room_created", "room_id", "created_at"),
Index("ix_tr_messages_room_sequence", "room_id", "sequence_number"),
Index("ix_tr_messages_sender", "sender_id"),
)
class MessageReaction(Base):
"""Message reaction model for emoji reactions"""
__tablename__ = "message_reactions"
__tablename__ = "tr_message_reactions"
reaction_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="CASCADE"), nullable=False)
user_id = Column(String(255), nullable=False) # User email/ID who reacted
emoji = Column(String(10), nullable=False) # Emoji character or code
@@ -79,18 +77,18 @@ class MessageReaction(Base):
# Constraints and indexes
__table_args__ = (
# Ensure unique reaction per user per message
UniqueConstraint("message_id", "user_id", "emoji", name="uq_message_reaction"),
Index("ix_message_reactions_message", "message_id"),
UniqueConstraint("message_id", "user_id", "emoji", name="uq_tr_message_reaction"),
Index("ix_tr_message_reactions_message", "message_id"),
)
class MessageEditHistory(Base):
"""Message edit history model for audit trail"""
__tablename__ = "message_edit_history"
__tablename__ = "tr_message_edit_history"
edit_id = Column(Integer, primary_key=True, autoincrement=True)
message_id = Column(String(36), ForeignKey("messages.message_id", ondelete="CASCADE"), nullable=False)
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="CASCADE"), nullable=False)
original_content = Column(Text, nullable=False) # Content before edit
edited_by = Column(String(255), nullable=False) # User who made the edit
@@ -102,5 +100,5 @@ class MessageEditHistory(Base):
# Indexes
__table_args__ = (
Index("ix_message_edit_history_message", "message_id", "edited_at"),
Index("ix_tr_message_edit_history_message", "message_id", "edited_at"),
)

View File

@@ -7,6 +7,7 @@ from datetime import datetime
import json
from app.core.database import get_db
from app.core.config import get_settings
from app.modules.auth.dependencies import get_current_user
from app.modules.auth.services.session_service import session_service
from app.modules.chat_room.models import RoomMember, MemberRole
@@ -32,7 +33,7 @@ from sqlalchemy import and_
router = APIRouter(prefix="/api", tags=["realtime"])
SYSTEM_ADMIN_EMAIL = "ymirliu@panjit.com.tw"
settings = get_settings()
async def ws_send_json(websocket: WebSocket, data: dict):
@@ -51,9 +52,14 @@ def get_user_room_membership(db: Session, room_id: str, user_id: str) -> Optiona
).first()
def is_system_admin(user_id: str) -> bool:
"""Check if user is the system administrator"""
return bool(settings.SYSTEM_ADMIN_EMAIL and user_id == settings.SYSTEM_ADMIN_EMAIL)
def can_write_message(membership: Optional[RoomMember], user_id: str) -> bool:
"""Check if user has write permission (OWNER or EDITOR)"""
if user_id == SYSTEM_ADMIN_EMAIL:
if is_system_admin(user_id):
return True
if not membership:
@@ -99,7 +105,7 @@ async def websocket_endpoint(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
await websocket.close(code=4001, reason="Not a member of this room")
return
@@ -225,12 +231,11 @@ async def websocket_endpoint(
continue
# Delete message
is_admin = user_id == SYSTEM_ADMIN_EMAIL
deleted_message = MessageService.delete_message(
db=db,
message_id=ws_message.message_id,
user_id=user_id,
is_admin=is_admin
is_admin=is_system_admin(user_id)
)
if not deleted_message:
@@ -345,7 +350,7 @@ async def get_messages(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.get_messages(
@@ -414,7 +419,7 @@ async def search_messages(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
return MessageService.search_messages(
@@ -437,7 +442,7 @@ async def get_online_users(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
online_users = manager.get_online_users(room_id)
@@ -455,7 +460,7 @@ async def get_typing_users(
# Check room membership
membership = get_user_room_membership(db, room_id, user_id)
if not membership and user_id != SYSTEM_ADMIN_EMAIL:
if not membership and not is_system_admin(user_id):
raise HTTPException(status_code=403, detail="Not a member of this room")
typing_users = manager.get_typing_users(room_id)

View File

@@ -5,6 +5,7 @@ from typing import List, Optional, Dict, Any
from datetime import datetime, timedelta
import uuid
from app.core.config import get_settings
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
from app.modules.realtime.schemas import (
MessageCreate,
@@ -13,6 +14,8 @@ from app.modules.realtime.schemas import (
ReactionSummary
)
settings = get_settings()
class MessageService:
"""Service for message operations"""
@@ -161,9 +164,9 @@ class MessageService:
if message.sender_id != user_id:
return None
# Check time limit (15 minutes)
# Check time limit (configurable via MESSAGE_EDIT_TIME_LIMIT_MINUTES)
time_diff = datetime.utcnow() - message.created_at
if time_diff > timedelta(minutes=15):
if time_diff > timedelta(minutes=settings.MESSAGE_EDIT_TIME_LIMIT_MINUTES):
return None
# Store original content in edit history

View File

@@ -6,6 +6,10 @@ import asyncio
import json
from collections import defaultdict
from app.core.config import get_settings
settings = get_settings()
def json_serializer(obj: Any) -> str:
"""Custom JSON serializer for objects not serializable by default json code"""
@@ -193,9 +197,11 @@ class WebSocketManager:
if user_id in self._typing_tasks:
self._typing_tasks[user_id].cancel()
# Set new timeout (3 seconds)
# Set new timeout (configurable via TYPING_TIMEOUT_SECONDS)
typing_timeout = settings.TYPING_TIMEOUT_SECONDS
async def clear_typing():
await asyncio.sleep(3)
await asyncio.sleep(typing_timeout)
self._typing_users[room_id].discard(user_id)
if user_id in self._typing_tasks:
del self._typing_tasks[user_id]

View File

@@ -1,7 +1,9 @@
"""SQLAlchemy models for report generation
Tables:
- generated_reports: Stores report metadata and generation status
- tr_generated_reports: Stores report metadata and generation status
Note: All tables use 'tr_' prefix to avoid conflicts in shared database.
"""
from sqlalchemy import Column, String, Text, DateTime, Integer, ForeignKey, Index, JSON
from sqlalchemy.orm import relationship
@@ -24,14 +26,14 @@ class ReportStatus(str, enum.Enum):
class GeneratedReport(Base):
"""Generated report model for incident reports"""
__tablename__ = "generated_reports"
__tablename__ = "tr_generated_reports"
report_id = Column(
String(36), primary_key=True, default=lambda: str(uuid.uuid4()),
comment="Unique report identifier (UUID)"
)
room_id = Column(
String(36), ForeignKey("incident_rooms.room_id", ondelete="CASCADE"),
String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"),
nullable=False, comment="Reference to incident room"
)
@@ -92,8 +94,8 @@ class GeneratedReport(Base):
# Indexes
__table_args__ = (
Index("ix_generated_reports_room_date", "room_id", "generated_at"),
Index("ix_generated_reports_status", "status"),
Index("ix_tr_generated_reports_room_date", "room_id", "generated_at"),
Index("ix_tr_generated_reports_status", "status"),
)
def __repr__(self):