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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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=["*"],
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user