feat: Add Chat UX improvements with notifications and @mention support
- Add ActionBar component with expandable toolbar for mobile - Add @mention functionality with autocomplete dropdown - Add browser notification system (push, sound, vibration) - Add NotificationSettings modal for user preferences - Add mention badges on room list cards - Add ReportPreview with Markdown rendering and copy/download - Add message copy functionality with hover actions - Add backend mentions field to messages with Alembic migration - Add lots field to rooms, remove templates - Optimize WebSocket database session handling - Various UX polish (animations, accessibility) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,12 @@ class Settings(BaseSettings):
|
||||
REPORT_MAX_MESSAGES: int = 200 # Summarize if exceeded
|
||||
REPORT_STORAGE_PATH: str = "reports" # MinIO path prefix for reports
|
||||
|
||||
# Database Connection Pool
|
||||
DB_POOL_SIZE: int = 20 # Number of persistent connections
|
||||
DB_MAX_OVERFLOW: int = 30 # Max additional connections beyond pool_size
|
||||
DB_POOL_TIMEOUT: int = 10 # Seconds to wait for available connection
|
||||
DB_POOL_RECYCLE: int = 1800 # Recycle connections after 30 minutes
|
||||
|
||||
@field_validator("LOG_LEVEL")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
|
||||
@@ -3,20 +3,23 @@
|
||||
Supports MySQL database with connection pooling.
|
||||
All tables use 'tr_' prefix to avoid conflicts in shared database.
|
||||
"""
|
||||
from contextlib import contextmanager
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.orm import sessionmaker, Session
|
||||
from app.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# Create engine with MySQL connection pooling
|
||||
# Pool settings are configurable via environment variables
|
||||
engine = create_engine(
|
||||
settings.DATABASE_URL,
|
||||
pool_size=5,
|
||||
max_overflow=10,
|
||||
pool_size=settings.DB_POOL_SIZE,
|
||||
max_overflow=settings.DB_MAX_OVERFLOW,
|
||||
pool_timeout=settings.DB_POOL_TIMEOUT,
|
||||
pool_pre_ping=True, # Verify connection before using
|
||||
pool_recycle=3600, # Recycle connections after 1 hour
|
||||
pool_recycle=settings.DB_POOL_RECYCLE,
|
||||
echo=settings.DEBUG,
|
||||
)
|
||||
|
||||
@@ -34,3 +37,21 @@ def get_db():
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_db_context() -> Session:
|
||||
"""Context manager for short-lived database sessions.
|
||||
|
||||
Use this for operations that need a database session but should
|
||||
not hold onto it for the entire request/connection lifecycle.
|
||||
|
||||
Example:
|
||||
with get_db_context() as db:
|
||||
result = db.query(Model).filter(...).first()
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
15
app/main.py
15
app/main.py
@@ -13,10 +13,10 @@ 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
|
||||
from app.modules.chat_room import router as chat_room_router
|
||||
from app.modules.chat_room.services.template_service import template_service
|
||||
from app.modules.realtime import router as realtime_router
|
||||
from app.modules.file_storage import router as file_storage_router
|
||||
from app.modules.report_generation import router as report_generation_router
|
||||
from app.modules.report_generation import health_router as report_health_router
|
||||
|
||||
# Frontend build directory
|
||||
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
|
||||
@@ -53,24 +53,17 @@ app.include_router(chat_room_router)
|
||||
app.include_router(realtime_router)
|
||||
app.include_router(file_storage_router)
|
||||
app.include_router(report_generation_router)
|
||||
app.include_router(report_health_router)
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize application on startup"""
|
||||
from app.core.database import SessionLocal
|
||||
from app.core.minio_client import initialize_bucket
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Initialize default templates
|
||||
db = SessionLocal()
|
||||
try:
|
||||
template_service.initialize_default_templates(db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
# Initialize MinIO bucket
|
||||
try:
|
||||
if initialize_bucket():
|
||||
@@ -80,6 +73,10 @@ async def startup_event():
|
||||
except Exception as e:
|
||||
logger.warning(f"MinIO connection failed: {e} - file uploads will be unavailable")
|
||||
|
||||
# Check DIFY API Key configuration
|
||||
if not settings.DIFY_API_KEY:
|
||||
logger.warning("DIFY_API_KEY not configured - AI report generation will be unavailable")
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
|
||||
@@ -26,6 +26,10 @@ class AuthMiddleware:
|
||||
async def __call__(self, request: Request, call_next):
|
||||
"""Process request through authentication checks"""
|
||||
|
||||
# Skip auth for CORS preflight requests (OPTIONS)
|
||||
if request.method == "OPTIONS":
|
||||
return await call_next(request)
|
||||
|
||||
# Skip auth for non-API routes (frontend), login/logout, and docs
|
||||
path = request.url.path
|
||||
if not path.startswith("/api") or path in ["/api/auth/login", "/api/auth/logout", "/api/health"]:
|
||||
|
||||
@@ -3,17 +3,14 @@
|
||||
Provides functionality for creating and managing incident response chat rooms
|
||||
"""
|
||||
from app.modules.chat_room.router import router
|
||||
from app.modules.chat_room.models import IncidentRoom, RoomMember, RoomTemplate
|
||||
from app.modules.chat_room.models import IncidentRoom, RoomMember
|
||||
from app.modules.chat_room.services.room_service import room_service
|
||||
from app.modules.chat_room.services.membership_service import membership_service
|
||||
from app.modules.chat_room.services.template_service import template_service
|
||||
|
||||
__all__ = [
|
||||
"router",
|
||||
"IncidentRoom",
|
||||
"RoomMember",
|
||||
"RoomTemplate",
|
||||
"room_service",
|
||||
"membership_service",
|
||||
"template_service"
|
||||
]
|
||||
@@ -3,11 +3,10 @@
|
||||
Tables:
|
||||
- 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 import Column, Integer, String, Text, DateTime, Enum, ForeignKey, UniqueConstraint, Index, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
from datetime import datetime
|
||||
import enum
|
||||
@@ -58,6 +57,7 @@ class IncidentRoom(Base):
|
||||
location = Column(String(255))
|
||||
description = Column(Text)
|
||||
resolution_notes = Column(Text)
|
||||
lots = Column(JSON, default=list, nullable=False, comment="LOT batch numbers (JSON array)")
|
||||
|
||||
# User tracking
|
||||
created_by = Column(String(255), nullable=False) # User email/ID who created the room
|
||||
@@ -111,18 +111,4 @@ class RoomMember(Base):
|
||||
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__ = "tr_room_templates"
|
||||
|
||||
template_id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), unique=True, nullable=False)
|
||||
description = Column(Text)
|
||||
incident_type = Column(Enum(IncidentType), nullable=False)
|
||||
default_severity = Column(Enum(SeverityLevel), nullable=False)
|
||||
default_members = Column(Text) # JSON array of user roles
|
||||
metadata_fields = Column(Text) # JSON schema for additional fields
|
||||
)
|
||||
@@ -13,7 +13,6 @@ from app.modules.chat_room import schemas
|
||||
from app.modules.chat_room.models import MemberRole, RoomStatus
|
||||
from app.modules.chat_room.services.room_service import room_service
|
||||
from app.modules.chat_room.services.membership_service import membership_service
|
||||
from app.modules.chat_room.services.template_service import template_service
|
||||
from app.modules.chat_room.dependencies import (
|
||||
get_current_room,
|
||||
require_room_permission,
|
||||
@@ -36,25 +35,7 @@ async def create_room(
|
||||
"""Create a new incident room"""
|
||||
user_email = current_user["username"]
|
||||
|
||||
# Check if using template
|
||||
if room_data.template:
|
||||
template = template_service.get_template_by_name(db, room_data.template)
|
||||
if template:
|
||||
room = template_service.create_room_from_template(
|
||||
db,
|
||||
template.template_id,
|
||||
user_email,
|
||||
room_data.title,
|
||||
room_data.location,
|
||||
room_data.description
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Template '{room_data.template}' not found"
|
||||
)
|
||||
else:
|
||||
room = room_service.create_room(db, user_email, room_data)
|
||||
room = room_service.create_room(db, user_email, room_data)
|
||||
|
||||
# Get user role for response
|
||||
role = membership_service.get_user_role_in_room(db, room.room_id, user_email)
|
||||
@@ -508,12 +489,56 @@ async def get_user_permissions(
|
||||
return permissions[role]
|
||||
|
||||
|
||||
# Template Endpoints
|
||||
@router.get("/templates", response_model=List[schemas.TemplateResponse])
|
||||
async def list_templates(
|
||||
db: Session = Depends(get_db),
|
||||
_: dict = Depends(get_current_user)
|
||||
# LOT Endpoints
|
||||
@router.post("/{room_id}/lots", response_model=List[str])
|
||||
async def add_lot(
|
||||
room_id: str,
|
||||
request: schemas.AddLotRequest,
|
||||
_: None = Depends(require_room_permission("update_metadata")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""List available room templates"""
|
||||
templates = template_service.get_templates(db)
|
||||
return [schemas.TemplateResponse.from_orm(t) for t in templates]
|
||||
"""Add a LOT batch number to the room"""
|
||||
from app.modules.chat_room.models import IncidentRoom
|
||||
|
||||
room = db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||
|
||||
# Get current lots or initialize empty list
|
||||
current_lots = room.lots or []
|
||||
|
||||
# Prevent duplicates
|
||||
if request.lot not in current_lots:
|
||||
current_lots.append(request.lot)
|
||||
room.lots = current_lots
|
||||
room.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
return room.lots
|
||||
|
||||
|
||||
@router.delete("/{room_id}/lots/{lot}", response_model=List[str])
|
||||
async def remove_lot(
|
||||
room_id: str,
|
||||
lot: str,
|
||||
_: None = Depends(require_room_permission("update_metadata")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Remove a LOT batch number from the room"""
|
||||
from app.modules.chat_room.models import IncidentRoom
|
||||
|
||||
room = db.query(IncidentRoom).filter(IncidentRoom.room_id == room_id).first()
|
||||
if not room:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Room not found")
|
||||
|
||||
current_lots = room.lots or []
|
||||
|
||||
if lot in current_lots:
|
||||
current_lots.remove(lot)
|
||||
room.lots = current_lots
|
||||
room.last_updated_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
|
||||
return room.lots
|
||||
@@ -46,7 +46,7 @@ class CreateRoomRequest(BaseModel):
|
||||
severity: SeverityLevel = Field(..., description="Severity level")
|
||||
location: Optional[str] = Field(None, max_length=255, description="Incident location")
|
||||
description: Optional[str] = Field(None, description="Detailed description")
|
||||
template: Optional[str] = Field(None, description="Template name to use")
|
||||
lots: Optional[List[str]] = Field(None, description="LOT batch numbers")
|
||||
|
||||
|
||||
class UpdateRoomRequest(BaseModel):
|
||||
@@ -57,6 +57,7 @@ class UpdateRoomRequest(BaseModel):
|
||||
location: Optional[str] = Field(None, max_length=255)
|
||||
description: Optional[str] = None
|
||||
resolution_notes: Optional[str] = None
|
||||
lots: Optional[List[str]] = Field(None, description="LOT batch numbers")
|
||||
|
||||
|
||||
class AddMemberRequest(BaseModel):
|
||||
@@ -111,6 +112,7 @@ class RoomResponse(BaseModel):
|
||||
location: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
resolution_notes: Optional[str] = None
|
||||
lots: List[str] = []
|
||||
created_by: str
|
||||
created_at: datetime
|
||||
resolved_at: Optional[datetime] = None
|
||||
@@ -137,18 +139,9 @@ class RoomListResponse(BaseModel):
|
||||
offset: int
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""Room template information"""
|
||||
template_id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
incident_type: IncidentType
|
||||
default_severity: SeverityLevel
|
||||
default_members: Optional[List[dict]] = None
|
||||
metadata_fields: Optional[dict] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
class AddLotRequest(BaseModel):
|
||||
"""Request to add a LOT to a room"""
|
||||
lot: str = Field(..., min_length=1, description="LOT batch number to add")
|
||||
|
||||
|
||||
class PermissionResponse(BaseModel):
|
||||
|
||||
@@ -4,10 +4,8 @@ Business logic for room management operations
|
||||
"""
|
||||
from app.modules.chat_room.services.room_service import room_service
|
||||
from app.modules.chat_room.services.membership_service import membership_service
|
||||
from app.modules.chat_room.services.template_service import template_service
|
||||
|
||||
__all__ = [
|
||||
"room_service",
|
||||
"membership_service",
|
||||
"template_service"
|
||||
]
|
||||
@@ -42,6 +42,7 @@ class RoomService:
|
||||
severity=room_data.severity,
|
||||
location=room_data.location,
|
||||
description=room_data.description,
|
||||
lots=room_data.lots or [],
|
||||
status=RoomStatus.ACTIVE,
|
||||
created_by=user_id,
|
||||
created_at=datetime.utcnow(),
|
||||
@@ -219,6 +220,9 @@ class RoomService:
|
||||
if updates.resolution_notes is not None:
|
||||
room.resolution_notes = updates.resolution_notes
|
||||
|
||||
if updates.lots is not None:
|
||||
room.lots = updates.lots
|
||||
|
||||
# Handle status transitions
|
||||
if updates.status is not None:
|
||||
if not self._validate_status_transition(room.status, updates.status):
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"""Template service for room templates
|
||||
|
||||
Handles business logic for room template operations
|
||||
"""
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Optional
|
||||
import json
|
||||
from datetime import datetime
|
||||
|
||||
from app.modules.chat_room.models import RoomTemplate, IncidentRoom, RoomMember, IncidentType, SeverityLevel, MemberRole
|
||||
from app.modules.chat_room.services.room_service import room_service
|
||||
from app.modules.chat_room.services.membership_service import membership_service
|
||||
|
||||
|
||||
class TemplateService:
|
||||
"""Service for room template operations"""
|
||||
|
||||
def get_templates(self, db: Session) -> List[RoomTemplate]:
|
||||
"""Get all available templates
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
List of templates
|
||||
"""
|
||||
return db.query(RoomTemplate).all()
|
||||
|
||||
def get_template_by_name(
|
||||
self,
|
||||
db: Session,
|
||||
template_name: str
|
||||
) -> Optional[RoomTemplate]:
|
||||
"""Get template by name
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
template_name: Template name
|
||||
|
||||
Returns:
|
||||
Template or None if not found
|
||||
"""
|
||||
return db.query(RoomTemplate).filter(
|
||||
RoomTemplate.name == template_name
|
||||
).first()
|
||||
|
||||
def create_room_from_template(
|
||||
self,
|
||||
db: Session,
|
||||
template_id: int,
|
||||
user_id: str,
|
||||
title: str,
|
||||
location: Optional[str] = None,
|
||||
description: Optional[str] = None
|
||||
) -> Optional[IncidentRoom]:
|
||||
"""Create a room from a template
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
template_id: Template ID
|
||||
user_id: User creating the room
|
||||
title: Room title
|
||||
location: Optional location override
|
||||
description: Optional description override
|
||||
|
||||
Returns:
|
||||
Created room or None if template not found
|
||||
"""
|
||||
# Get template
|
||||
template = db.query(RoomTemplate).filter(
|
||||
RoomTemplate.template_id == template_id
|
||||
).first()
|
||||
|
||||
if not template:
|
||||
return None
|
||||
|
||||
# Create room with template defaults
|
||||
room = IncidentRoom(
|
||||
title=title,
|
||||
incident_type=template.incident_type,
|
||||
severity=template.default_severity,
|
||||
location=location,
|
||||
description=description or template.description,
|
||||
created_by=user_id,
|
||||
status="active",
|
||||
created_at=datetime.utcnow(),
|
||||
last_activity_at=datetime.utcnow(),
|
||||
last_updated_at=datetime.utcnow(),
|
||||
member_count=1
|
||||
)
|
||||
db.add(room)
|
||||
db.flush() # Get room_id
|
||||
|
||||
# Add creator as owner
|
||||
owner = RoomMember(
|
||||
room_id=room.room_id,
|
||||
user_id=user_id,
|
||||
role=MemberRole.OWNER,
|
||||
added_by=user_id,
|
||||
added_at=datetime.utcnow()
|
||||
)
|
||||
db.add(owner)
|
||||
|
||||
# Add default members from template
|
||||
if template.default_members:
|
||||
try:
|
||||
default_members = json.loads(template.default_members)
|
||||
for member_config in default_members:
|
||||
if member_config.get("user_id") != user_id: # Don't duplicate owner
|
||||
member = RoomMember(
|
||||
room_id=room.room_id,
|
||||
user_id=member_config["user_id"],
|
||||
role=member_config.get("role", MemberRole.VIEWER),
|
||||
added_by=user_id,
|
||||
added_at=datetime.utcnow()
|
||||
)
|
||||
db.add(member)
|
||||
room.member_count += 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
# Invalid template configuration, skip default members
|
||||
pass
|
||||
|
||||
db.commit()
|
||||
db.refresh(room)
|
||||
return room
|
||||
|
||||
def initialize_default_templates(self, db: Session) -> None:
|
||||
"""Initialize default templates if none exist
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
"""
|
||||
# Check if templates already exist
|
||||
existing = db.query(RoomTemplate).count()
|
||||
if existing > 0:
|
||||
return
|
||||
|
||||
# Create default templates
|
||||
templates = [
|
||||
RoomTemplate(
|
||||
name="equipment_failure",
|
||||
description="Equipment failure incident requiring immediate attention",
|
||||
incident_type=IncidentType.EQUIPMENT_FAILURE,
|
||||
default_severity=SeverityLevel.HIGH,
|
||||
default_members=json.dumps([
|
||||
{"user_id": "maintenance_team@panjit.com.tw", "role": "editor"},
|
||||
{"user_id": "engineering@panjit.com.tw", "role": "viewer"}
|
||||
])
|
||||
),
|
||||
RoomTemplate(
|
||||
name="material_shortage",
|
||||
description="Material shortage affecting production",
|
||||
incident_type=IncidentType.MATERIAL_SHORTAGE,
|
||||
default_severity=SeverityLevel.MEDIUM,
|
||||
default_members=json.dumps([
|
||||
{"user_id": "procurement@panjit.com.tw", "role": "editor"},
|
||||
{"user_id": "logistics@panjit.com.tw", "role": "editor"}
|
||||
])
|
||||
),
|
||||
RoomTemplate(
|
||||
name="quality_issue",
|
||||
description="Quality control issue requiring investigation",
|
||||
incident_type=IncidentType.QUALITY_ISSUE,
|
||||
default_severity=SeverityLevel.HIGH,
|
||||
default_members=json.dumps([
|
||||
{"user_id": "quality_team@panjit.com.tw", "role": "editor"},
|
||||
{"user_id": "production_manager@panjit.com.tw", "role": "viewer"}
|
||||
])
|
||||
)
|
||||
]
|
||||
|
||||
for template in templates:
|
||||
db.add(template)
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
# Create singleton instance
|
||||
template_service = TemplateService()
|
||||
@@ -35,9 +35,12 @@ class Message(Base):
|
||||
content = Column(Text, nullable=False)
|
||||
message_type = Column(Enum(MessageType), default=MessageType.TEXT, nullable=False)
|
||||
|
||||
# Message metadata for structured data, mentions, file references, etc.
|
||||
# Message metadata for structured data, file references, etc.
|
||||
message_metadata = Column(JSON)
|
||||
|
||||
# @Mention tracking - stores array of mentioned user_ids
|
||||
mentions = Column(JSON, default=list)
|
||||
|
||||
# Timestamps
|
||||
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
|
||||
edited_at = Column(DateTime) # Last edit timestamp
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Optional
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.core.database import get_db, get_db_context
|
||||
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
|
||||
@@ -87,15 +87,17 @@ async def websocket_endpoint(
|
||||
3. Connection added to pool
|
||||
4. User joined event broadcast to room
|
||||
5. Client can send/receive messages
|
||||
|
||||
Note: Uses short-lived database sessions for each operation to prevent
|
||||
connection pool exhaustion with many concurrent WebSocket connections.
|
||||
"""
|
||||
db: Session = next(get_db())
|
||||
|
||||
try:
|
||||
# Authenticate token via session lookup
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
# Authenticate and get user info using short-lived session
|
||||
if not token:
|
||||
await websocket.close(code=4001, reason="Authentication required")
|
||||
return
|
||||
|
||||
# Authenticate token and check membership with short session
|
||||
with get_db_context() as db:
|
||||
user_session = session_service.get_session_by_token(db, token)
|
||||
if not user_session:
|
||||
await websocket.close(code=4001, reason="Invalid or expired token")
|
||||
@@ -103,55 +105,59 @@ async def websocket_endpoint(
|
||||
|
||||
user_id = user_session.username
|
||||
|
||||
# Check room membership
|
||||
# Check room membership and cache the role
|
||||
membership = get_user_room_membership(db, room_id, user_id)
|
||||
if not membership and not is_system_admin(user_id):
|
||||
await websocket.close(code=4001, reason="Not a member of this room")
|
||||
return
|
||||
|
||||
# Connect to WebSocket manager
|
||||
conn_info = await manager.connect(websocket, room_id, user_id)
|
||||
# Cache membership role for permission checks (avoid holding DB reference)
|
||||
user_role = membership.role if membership else None
|
||||
|
||||
# Broadcast user joined event
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
SystemMessageBroadcast(
|
||||
event=SystemEventType.USER_JOINED,
|
||||
user_id=user_id,
|
||||
room_id=room_id,
|
||||
timestamp=datetime.utcnow()
|
||||
).dict(),
|
||||
exclude_user=user_id
|
||||
)
|
||||
# Connect to WebSocket manager (no DB needed)
|
||||
conn_info = await manager.connect(websocket, room_id, user_id)
|
||||
|
||||
try:
|
||||
while True:
|
||||
# Receive message from client
|
||||
data = await websocket.receive_text()
|
||||
message_data = json.loads(data)
|
||||
# Broadcast user joined event (no DB needed)
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
SystemMessageBroadcast(
|
||||
event=SystemEventType.USER_JOINED,
|
||||
user_id=user_id,
|
||||
room_id=room_id,
|
||||
timestamp=datetime.utcnow()
|
||||
).dict(),
|
||||
exclude_user=user_id
|
||||
)
|
||||
|
||||
# Parse incoming message
|
||||
try:
|
||||
ws_message = WebSocketMessageIn(**message_data)
|
||||
except Exception as e:
|
||||
try:
|
||||
while True:
|
||||
# Receive message from client
|
||||
data = await websocket.receive_text()
|
||||
message_data = json.loads(data)
|
||||
|
||||
# Parse incoming message
|
||||
try:
|
||||
ws_message = WebSocketMessageIn(**message_data)
|
||||
except Exception as e:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle different message types
|
||||
if ws_message.type == WebSocketMessageType.MESSAGE:
|
||||
# Check write permission using cached role
|
||||
if not _can_write_with_role(user_role, user_id):
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error=str(e), code="INVALID_MESSAGE").dict()
|
||||
ErrorMessage(
|
||||
error="Insufficient permissions",
|
||||
code="PERMISSION_DENIED"
|
||||
).dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Handle different message types
|
||||
if ws_message.type == WebSocketMessageType.MESSAGE:
|
||||
# Check write permission
|
||||
if not can_write_message(membership, user_id):
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(
|
||||
error="Insufficient permissions",
|
||||
code="PERMISSION_DENIED"
|
||||
).dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Create message in database
|
||||
# Create message in database with short session
|
||||
with get_db_context() as db:
|
||||
message = MessageService.create_message(
|
||||
db=db,
|
||||
room_id=room_id,
|
||||
@@ -160,131 +166,170 @@ async def websocket_endpoint(
|
||||
message_type=MessageType(ws_message.message_type.value) if ws_message.message_type else MessageType.TEXT,
|
||||
metadata=ws_message.metadata
|
||||
)
|
||||
# Get sender display name
|
||||
display_name = MessageService.get_display_name(db, user_id)
|
||||
# Extract data before session closes
|
||||
msg_data = {
|
||||
"message_id": message.message_id,
|
||||
"room_id": message.room_id,
|
||||
"sender_id": message.sender_id,
|
||||
"sender_display_name": display_name or user_id,
|
||||
"content": message.content,
|
||||
"message_type": message.message_type.value,
|
||||
"metadata": message.message_metadata,
|
||||
"created_at": message.created_at,
|
||||
"sequence_number": message.sequence_number
|
||||
}
|
||||
|
||||
# Send acknowledgment to sender
|
||||
# Send acknowledgment to sender
|
||||
await ws_send_json(websocket,
|
||||
MessageAck(
|
||||
message_id=msg_data["message_id"],
|
||||
sequence_number=msg_data["sequence_number"],
|
||||
timestamp=msg_data["created_at"]
|
||||
).dict()
|
||||
)
|
||||
|
||||
# Broadcast message to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
MessageBroadcast(
|
||||
message_id=msg_data["message_id"],
|
||||
room_id=msg_data["room_id"],
|
||||
sender_id=msg_data["sender_id"],
|
||||
sender_display_name=msg_data["sender_display_name"],
|
||||
content=msg_data["content"],
|
||||
message_type=MessageTypeEnum(msg_data["message_type"]),
|
||||
metadata=msg_data["metadata"],
|
||||
created_at=msg_data["created_at"],
|
||||
sequence_number=msg_data["sequence_number"]
|
||||
).dict()
|
||||
)
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
|
||||
if not ws_message.message_id or not ws_message.content:
|
||||
await ws_send_json(websocket,
|
||||
MessageAck(
|
||||
message_id=message.message_id,
|
||||
sequence_number=message.sequence_number,
|
||||
timestamp=message.created_at
|
||||
).dict()
|
||||
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Broadcast message to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
MessageBroadcast(
|
||||
message_id=message.message_id,
|
||||
room_id=message.room_id,
|
||||
sender_id=message.sender_id,
|
||||
content=message.content,
|
||||
message_type=MessageTypeEnum(message.message_type.value),
|
||||
metadata=message.message_metadata,
|
||||
created_at=message.created_at,
|
||||
sequence_number=message.sequence_number
|
||||
).dict()
|
||||
)
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.EDIT_MESSAGE:
|
||||
if not ws_message.message_id or not ws_message.content:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id or content", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Edit message
|
||||
# Edit message with short session
|
||||
with get_db_context() as db:
|
||||
edited_message = MessageService.edit_message(
|
||||
db=db,
|
||||
message_id=ws_message.message_id,
|
||||
user_id=user_id,
|
||||
new_content=ws_message.content
|
||||
)
|
||||
if edited_message:
|
||||
# Get sender display name
|
||||
display_name = MessageService.get_display_name(db, edited_message.sender_id)
|
||||
edit_data = {
|
||||
"message_id": edited_message.message_id,
|
||||
"room_id": edited_message.room_id,
|
||||
"sender_id": edited_message.sender_id,
|
||||
"sender_display_name": display_name or edited_message.sender_id,
|
||||
"content": edited_message.content,
|
||||
"message_type": edited_message.message_type.value,
|
||||
"metadata": edited_message.message_metadata,
|
||||
"created_at": edited_message.created_at,
|
||||
"edited_at": edited_message.edited_at,
|
||||
"sequence_number": edited_message.sequence_number
|
||||
}
|
||||
else:
|
||||
edit_data = None
|
||||
|
||||
if not edited_message:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Broadcast edit to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
MessageBroadcast(
|
||||
type="edit_message",
|
||||
message_id=edited_message.message_id,
|
||||
room_id=edited_message.room_id,
|
||||
sender_id=edited_message.sender_id,
|
||||
content=edited_message.content,
|
||||
message_type=MessageTypeEnum(edited_message.message_type.value),
|
||||
metadata=edited_message.message_metadata,
|
||||
created_at=edited_message.created_at,
|
||||
edited_at=edited_message.edited_at,
|
||||
sequence_number=edited_message.sequence_number
|
||||
).dict()
|
||||
if not edit_data:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Cannot edit message", code="EDIT_FAILED").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
|
||||
if not ws_message.message_id:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
# Broadcast edit to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
MessageBroadcast(
|
||||
type="edit_message",
|
||||
message_id=edit_data["message_id"],
|
||||
room_id=edit_data["room_id"],
|
||||
sender_id=edit_data["sender_id"],
|
||||
sender_display_name=edit_data["sender_display_name"],
|
||||
content=edit_data["content"],
|
||||
message_type=MessageTypeEnum(edit_data["message_type"]),
|
||||
metadata=edit_data["metadata"],
|
||||
created_at=edit_data["created_at"],
|
||||
edited_at=edit_data["edited_at"],
|
||||
sequence_number=edit_data["sequence_number"]
|
||||
).dict()
|
||||
)
|
||||
|
||||
# Delete message
|
||||
elif ws_message.type == WebSocketMessageType.DELETE_MESSAGE:
|
||||
if not ws_message.message_id:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Delete message with short session
|
||||
with get_db_context() as db:
|
||||
deleted_message = MessageService.delete_message(
|
||||
db=db,
|
||||
message_id=ws_message.message_id,
|
||||
user_id=user_id,
|
||||
is_admin=is_system_admin(user_id)
|
||||
)
|
||||
deleted_msg_id = deleted_message.message_id if deleted_message else None
|
||||
|
||||
if not deleted_message:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Broadcast deletion to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{"type": "delete_message", "message_id": deleted_message.message_id}
|
||||
if not deleted_msg_id:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Cannot delete message", code="DELETE_FAILED").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
|
||||
if not ws_message.message_id or not ws_message.emoji:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
# Broadcast deletion to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{"type": "delete_message", "message_id": deleted_msg_id}
|
||||
)
|
||||
|
||||
# Add reaction
|
||||
elif ws_message.type == WebSocketMessageType.ADD_REACTION:
|
||||
if not ws_message.message_id or not ws_message.emoji:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Add reaction with short session
|
||||
with get_db_context() as db:
|
||||
reaction = MessageService.add_reaction(
|
||||
db=db,
|
||||
message_id=ws_message.message_id,
|
||||
user_id=user_id,
|
||||
emoji=ws_message.emoji
|
||||
)
|
||||
reaction_added = reaction is not None
|
||||
|
||||
if reaction:
|
||||
# Broadcast reaction to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{
|
||||
"type": "add_reaction",
|
||||
"message_id": ws_message.message_id,
|
||||
"user_id": user_id,
|
||||
"emoji": ws_message.emoji
|
||||
}
|
||||
)
|
||||
if reaction_added:
|
||||
# Broadcast reaction to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{
|
||||
"type": "add_reaction",
|
||||
"message_id": ws_message.message_id,
|
||||
"user_id": user_id,
|
||||
"emoji": ws_message.emoji
|
||||
}
|
||||
)
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
|
||||
if not ws_message.message_id or not ws_message.emoji:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
elif ws_message.type == WebSocketMessageType.REMOVE_REACTION:
|
||||
if not ws_message.message_id or not ws_message.emoji:
|
||||
await ws_send_json(websocket,
|
||||
ErrorMessage(error="Missing message_id or emoji", code="INVALID_REQUEST").dict()
|
||||
)
|
||||
continue
|
||||
|
||||
# Remove reaction
|
||||
# Remove reaction with short session
|
||||
with get_db_context() as db:
|
||||
removed = MessageService.remove_reaction(
|
||||
db=db,
|
||||
message_id=ws_message.message_id,
|
||||
@@ -292,47 +337,53 @@ async def websocket_endpoint(
|
||||
emoji=ws_message.emoji
|
||||
)
|
||||
|
||||
if removed:
|
||||
# Broadcast reaction removal to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{
|
||||
"type": "remove_reaction",
|
||||
"message_id": ws_message.message_id,
|
||||
"user_id": user_id,
|
||||
"emoji": ws_message.emoji
|
||||
}
|
||||
)
|
||||
|
||||
elif ws_message.type == WebSocketMessageType.TYPING:
|
||||
# Set typing status
|
||||
is_typing = message_data.get("is_typing", True)
|
||||
await manager.set_typing(room_id, user_id, is_typing)
|
||||
|
||||
# Broadcast typing status to other room members
|
||||
if removed:
|
||||
# Broadcast reaction removal to all room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{"type": "typing", "user_id": user_id, "is_typing": is_typing},
|
||||
exclude_user=user_id
|
||||
{
|
||||
"type": "remove_reaction",
|
||||
"message_id": ws_message.message_id,
|
||||
"user_id": user_id,
|
||||
"emoji": ws_message.emoji
|
||||
}
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
# Disconnect and broadcast user left event
|
||||
await manager.disconnect(conn_info)
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
SystemMessageBroadcast(
|
||||
event=SystemEventType.USER_LEFT,
|
||||
user_id=user_id,
|
||||
room_id=room_id,
|
||||
timestamp=datetime.utcnow()
|
||||
).dict()
|
||||
)
|
||||
elif ws_message.type == WebSocketMessageType.TYPING:
|
||||
# Set typing status (no DB needed)
|
||||
is_typing = message_data.get("is_typing", True)
|
||||
await manager.set_typing(room_id, user_id, is_typing)
|
||||
|
||||
# Broadcast typing status to other room members
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
{"type": "typing", "user_id": user_id, "is_typing": is_typing},
|
||||
exclude_user=user_id
|
||||
)
|
||||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
db.close()
|
||||
# Disconnect and broadcast user left event
|
||||
await manager.disconnect(conn_info)
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
SystemMessageBroadcast(
|
||||
event=SystemEventType.USER_LEFT,
|
||||
user_id=user_id,
|
||||
room_id=room_id,
|
||||
timestamp=datetime.utcnow()
|
||||
).dict()
|
||||
)
|
||||
|
||||
|
||||
def _can_write_with_role(role: Optional[MemberRole], user_id: str) -> bool:
|
||||
"""Check if user has write permission based on cached role"""
|
||||
if is_system_admin(user_id):
|
||||
return True
|
||||
if not role:
|
||||
return False
|
||||
return role in [MemberRole.OWNER, MemberRole.EDITOR]
|
||||
|
||||
|
||||
# REST API endpoints
|
||||
@@ -387,6 +438,10 @@ async def create_message(
|
||||
metadata=message.metadata
|
||||
)
|
||||
|
||||
# Get sender display name
|
||||
display_name = MessageService.get_display_name(db, user_id)
|
||||
sender_display_name = display_name or user_id
|
||||
|
||||
# Broadcast to WebSocket connections
|
||||
await manager.broadcast_to_room(
|
||||
room_id,
|
||||
@@ -394,6 +449,7 @@ async def create_message(
|
||||
message_id=created_message.message_id,
|
||||
room_id=created_message.room_id,
|
||||
sender_id=created_message.sender_id,
|
||||
sender_display_name=sender_display_name,
|
||||
content=created_message.content,
|
||||
message_type=MessageTypeEnum(created_message.message_type.value),
|
||||
metadata=created_message.message_metadata,
|
||||
@@ -402,7 +458,10 @@ async def create_message(
|
||||
).dict()
|
||||
)
|
||||
|
||||
return MessageResponse.from_orm(created_message)
|
||||
# Build response with display name
|
||||
response = MessageResponse.from_orm(created_message)
|
||||
response.sender_display_name = sender_display_name
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/rooms/{room_id}/messages/search", response_model=MessageListResponse)
|
||||
|
||||
@@ -80,6 +80,7 @@ class MessageBroadcast(BaseModel):
|
||||
message_id: str
|
||||
room_id: str
|
||||
sender_id: str
|
||||
sender_display_name: Optional[str] = None # Display name from users table
|
||||
content: str
|
||||
message_type: MessageTypeEnum
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
@@ -141,6 +142,7 @@ class MessageResponse(BaseModel):
|
||||
message_id: str
|
||||
room_id: str
|
||||
sender_id: str
|
||||
sender_display_name: Optional[str] = None # Display name from users table
|
||||
content: str
|
||||
message_type: MessageTypeEnum
|
||||
metadata: Optional[Dict[str, Any]] = Field(None, alias="message_metadata")
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Message service layer for database operations"""
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc, and_, func
|
||||
from typing import List, Optional, Dict, Any
|
||||
from sqlalchemy import desc, and_, func, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from typing import List, Optional, Dict, Any, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
import uuid
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.modules.realtime.models import Message, MessageType, MessageReaction, MessageEditHistory
|
||||
@@ -13,13 +16,48 @@ from app.modules.realtime.schemas import (
|
||||
MessageListResponse,
|
||||
ReactionSummary
|
||||
)
|
||||
from app.modules.auth.models import User
|
||||
|
||||
settings = get_settings()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MessageService:
|
||||
"""Service for message operations"""
|
||||
|
||||
@staticmethod
|
||||
def parse_mentions(content: str, room_members: List[Dict[str, str]]) -> List[str]:
|
||||
"""
|
||||
Parse @mentions from message content and resolve to user IDs
|
||||
|
||||
Args:
|
||||
content: Message content that may contain @mentions
|
||||
room_members: List of room members with user_id and display_name
|
||||
|
||||
Returns:
|
||||
List of mentioned user_ids
|
||||
"""
|
||||
# Pattern matches @displayname (alphanumeric, spaces, Chinese chars, etc.)
|
||||
# Captures text after @ until we hit a character that's not part of a name
|
||||
mention_pattern = r'@(\S+)'
|
||||
matches = re.findall(mention_pattern, content)
|
||||
|
||||
mentioned_ids = []
|
||||
for mention_text in matches:
|
||||
# Try to match against display names or user IDs
|
||||
for member in room_members:
|
||||
display_name = member.get('display_name', '') or member.get('user_id', '')
|
||||
user_id = member.get('user_id', '')
|
||||
|
||||
# Match against display_name or user_id (case-insensitive)
|
||||
if (mention_text.lower() == display_name.lower() or
|
||||
mention_text.lower() == user_id.lower()):
|
||||
if user_id not in mentioned_ids:
|
||||
mentioned_ids.append(user_id)
|
||||
break
|
||||
|
||||
return mentioned_ids
|
||||
|
||||
@staticmethod
|
||||
def create_message(
|
||||
db: Session,
|
||||
@@ -27,10 +65,16 @@ class MessageService:
|
||||
sender_id: str,
|
||||
content: str,
|
||||
message_type: MessageType = MessageType.TEXT,
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
mentions: Optional[List[str]] = None,
|
||||
max_retries: int = 3
|
||||
) -> Message:
|
||||
"""
|
||||
Create a new message
|
||||
Create a new message with race condition protection
|
||||
|
||||
Uses SELECT ... FOR UPDATE to lock the sequence number calculation,
|
||||
preventing duplicate sequence numbers when multiple users send
|
||||
messages simultaneously.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
@@ -38,34 +82,72 @@ class MessageService:
|
||||
sender_id: User ID who sent the message
|
||||
content: Message content
|
||||
message_type: Type of message
|
||||
metadata: Optional metadata (mentions, file refs, etc.)
|
||||
metadata: Optional metadata (file refs, etc.)
|
||||
mentions: List of mentioned user_ids (parsed from @mentions)
|
||||
max_retries: Maximum retry attempts for deadlock handling
|
||||
|
||||
Returns:
|
||||
Created Message object
|
||||
|
||||
Raises:
|
||||
IntegrityError: If max retries exceeded
|
||||
"""
|
||||
# Get next sequence number for this room
|
||||
max_seq = db.query(func.max(Message.sequence_number)).filter(
|
||||
Message.room_id == room_id
|
||||
).scalar()
|
||||
next_seq = (max_seq or 0) + 1
|
||||
last_error = None
|
||||
|
||||
message = Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
room_id=room_id,
|
||||
sender_id=sender_id,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
message_metadata=metadata or {},
|
||||
created_at=datetime.utcnow(),
|
||||
sequence_number=next_seq
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Use FOR UPDATE to lock rows while calculating next sequence
|
||||
# This prevents race conditions where two transactions read
|
||||
# the same max_seq and try to insert duplicate sequence numbers
|
||||
result = db.execute(
|
||||
text("""
|
||||
SELECT COALESCE(MAX(sequence_number), 0)
|
||||
FROM tr_messages
|
||||
WHERE room_id = :room_id
|
||||
FOR UPDATE
|
||||
"""),
|
||||
{"room_id": room_id}
|
||||
)
|
||||
max_seq = result.scalar()
|
||||
next_seq = (max_seq or 0) + 1
|
||||
|
||||
message = Message(
|
||||
message_id=str(uuid.uuid4()),
|
||||
room_id=room_id,
|
||||
sender_id=sender_id,
|
||||
content=content,
|
||||
message_type=message_type,
|
||||
message_metadata=metadata or {},
|
||||
mentions=mentions or [],
|
||||
created_at=datetime.utcnow(),
|
||||
sequence_number=next_seq
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return message
|
||||
|
||||
except IntegrityError as e:
|
||||
last_error = e
|
||||
db.rollback()
|
||||
logger.warning(
|
||||
f"Sequence number conflict on attempt {attempt + 1}/{max_retries} "
|
||||
f"for room {room_id}: {e}"
|
||||
)
|
||||
if attempt == max_retries - 1:
|
||||
logger.error(
|
||||
f"Failed to create message after {max_retries} attempts "
|
||||
f"for room {room_id}"
|
||||
)
|
||||
raise
|
||||
|
||||
# Should not reach here, but just in case
|
||||
raise last_error if last_error else IntegrityError(
|
||||
"Failed to create message", None, None
|
||||
)
|
||||
|
||||
db.add(message)
|
||||
db.commit()
|
||||
db.refresh(message)
|
||||
|
||||
return message
|
||||
|
||||
@staticmethod
|
||||
def get_message(db: Session, message_id: str) -> Optional[Message]:
|
||||
"""
|
||||
@@ -83,6 +165,21 @@ class MessageService:
|
||||
Message.deleted_at.is_(None)
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def get_display_name(db: Session, sender_id: str) -> Optional[str]:
|
||||
"""
|
||||
Get display name for a sender from users table
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
sender_id: User ID (email)
|
||||
|
||||
Returns:
|
||||
Display name or None if not found
|
||||
"""
|
||||
user = db.query(User.display_name).filter(User.user_id == sender_id).first()
|
||||
return user[0] if user else None
|
||||
|
||||
@staticmethod
|
||||
def get_messages(
|
||||
db: Session,
|
||||
@@ -106,7 +203,10 @@ class MessageService:
|
||||
Returns:
|
||||
MessageListResponse with messages and pagination info
|
||||
"""
|
||||
query = db.query(Message).filter(Message.room_id == room_id)
|
||||
# Build base query with LEFT JOIN to users table for display names
|
||||
query = db.query(Message, User.display_name).outerjoin(
|
||||
User, Message.sender_id == User.user_id
|
||||
).filter(Message.room_id == room_id)
|
||||
|
||||
if not include_deleted:
|
||||
query = query.filter(Message.deleted_at.is_(None))
|
||||
@@ -114,18 +214,24 @@ class MessageService:
|
||||
if before_timestamp:
|
||||
query = query.filter(Message.created_at < before_timestamp)
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
# Get total count (need separate query without join for accurate count)
|
||||
count_query = db.query(Message).filter(Message.room_id == room_id)
|
||||
if not include_deleted:
|
||||
count_query = count_query.filter(Message.deleted_at.is_(None))
|
||||
if before_timestamp:
|
||||
count_query = count_query.filter(Message.created_at < before_timestamp)
|
||||
total = count_query.count()
|
||||
|
||||
# Get messages in reverse chronological order
|
||||
messages = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all()
|
||||
# Get messages with display names in reverse chronological order
|
||||
results = query.order_by(desc(Message.created_at)).offset(offset).limit(limit).all()
|
||||
|
||||
# Get reaction counts for each message
|
||||
# Get reaction counts for each message and build responses
|
||||
message_responses = []
|
||||
for msg in messages:
|
||||
for msg, display_name in results:
|
||||
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
|
||||
msg_response = MessageResponse.from_orm(msg)
|
||||
msg_response.reaction_counts = reaction_counts
|
||||
msg_response.sender_display_name = display_name or msg.sender_id
|
||||
message_responses.append(msg_response)
|
||||
|
||||
return MessageListResponse(
|
||||
@@ -133,7 +239,7 @@ class MessageService:
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(messages)) < total
|
||||
has_more=(offset + len(results)) < total
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -253,8 +359,10 @@ class MessageService:
|
||||
|
||||
total = db.query(Message).filter(search_filter).count()
|
||||
|
||||
messages = (
|
||||
db.query(Message)
|
||||
# Query with LEFT JOIN for display names
|
||||
results = (
|
||||
db.query(Message, User.display_name)
|
||||
.outerjoin(User, Message.sender_id == User.user_id)
|
||||
.filter(search_filter)
|
||||
.order_by(desc(Message.created_at))
|
||||
.offset(offset)
|
||||
@@ -263,10 +371,11 @@ class MessageService:
|
||||
)
|
||||
|
||||
message_responses = []
|
||||
for msg in messages:
|
||||
for msg, display_name in results:
|
||||
reaction_counts = MessageService._get_reaction_counts(db, msg.message_id)
|
||||
msg_response = MessageResponse.from_orm(msg)
|
||||
msg_response.reaction_counts = reaction_counts
|
||||
msg_response.sender_display_name = display_name or msg.sender_id
|
||||
message_responses.append(msg_response)
|
||||
|
||||
return MessageListResponse(
|
||||
@@ -274,7 +383,7 @@ class MessageService:
|
||||
total=total,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
has_more=(offset + len(messages)) < total
|
||||
has_more=(offset + len(results)) < total
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
AI-powered incident report generation using DIFY service.
|
||||
"""
|
||||
from app.modules.report_generation.models import GeneratedReport, ReportStatus
|
||||
from app.modules.report_generation.router import router
|
||||
from app.modules.report_generation.router import router, health_router
|
||||
|
||||
__all__ = ["GeneratedReport", "ReportStatus", "router"]
|
||||
__all__ = ["GeneratedReport", "ReportStatus", "router", "health_router"]
|
||||
|
||||
@@ -89,6 +89,10 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
|
||||
elif resolved_at is None:
|
||||
resolved_at = "尚未解決"
|
||||
|
||||
# Format LOT batch numbers
|
||||
lots = room_data.get('lots', [])
|
||||
lots_str = ", ".join(lots) if lots else "無"
|
||||
|
||||
lines = [
|
||||
"## 事件資訊",
|
||||
f"- 標題: {room_data.get('title', '未命名')}",
|
||||
@@ -96,6 +100,7 @@ def _format_room_info(room_data: Dict[str, Any]) -> str:
|
||||
f"- 嚴重程度: {severity}",
|
||||
f"- 目前狀態: {status}",
|
||||
f"- 發生地點: {room_data.get('location', '未指定')}",
|
||||
f"- 影響批號 (LOT): {lots_str}",
|
||||
f"- 建立時間: {created_at}",
|
||||
f"- 解決時間: {resolved_at}",
|
||||
]
|
||||
@@ -189,7 +194,7 @@ def _format_instructions() -> str:
|
||||
|
||||
請根據以上資料,生成一份結構化的事件報告。報告必須為 JSON 格式,包含以下欄位:
|
||||
|
||||
1. **summary**: 事件摘要 (50-100字)
|
||||
1. **summary**: 事件摘要 (50-300字)
|
||||
2. **timeline**: 按時間順序的事件時間軸
|
||||
3. **participants**: 參與人員及其角色
|
||||
4. **resolution_process**: 詳細的處理過程描述
|
||||
|
||||
@@ -7,6 +7,7 @@ FastAPI router with all report-related endpoints:
|
||||
- GET /api/rooms/{room_id}/reports/{report_id}/download - Download report .docx
|
||||
"""
|
||||
import logging
|
||||
from urllib.parse import quote
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -64,6 +65,33 @@ async def _broadcast_report_progress(
|
||||
await ws_manager.broadcast_to_room(room_id, payload)
|
||||
|
||||
router = APIRouter(prefix="/api/rooms/{room_id}/reports", tags=["Report Generation"])
|
||||
health_router = APIRouter(prefix="/api/reports", tags=["Report Generation"])
|
||||
|
||||
|
||||
@health_router.get("/health", response_model=schemas.HealthCheckResponse)
|
||||
async def check_dify_health():
|
||||
"""Check DIFY AI service connection status
|
||||
|
||||
Returns:
|
||||
Health check result with status and message
|
||||
"""
|
||||
if not settings.DIFY_API_KEY:
|
||||
return schemas.HealthCheckResponse(
|
||||
status="error",
|
||||
message="DIFY_API_KEY 未設定,請聯繫系統管理員"
|
||||
)
|
||||
|
||||
try:
|
||||
result = await dify_service.test_connection()
|
||||
return schemas.HealthCheckResponse(
|
||||
status=result["status"],
|
||||
message=result["message"]
|
||||
)
|
||||
except DifyAPIError as e:
|
||||
return schemas.HealthCheckResponse(
|
||||
status="error",
|
||||
message=str(e)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/generate", response_model=schemas.ReportGenerateResponse, status_code=status.HTTP_202_ACCEPTED)
|
||||
@@ -202,6 +230,75 @@ async def get_report_status(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{report_id}/markdown", response_model=schemas.ReportMarkdownResponse)
|
||||
async def get_report_markdown(
|
||||
room_id: str,
|
||||
report_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
room_context: dict = Depends(require_room_member),
|
||||
):
|
||||
"""Get report content as Markdown for in-page preview
|
||||
|
||||
Args:
|
||||
room_id: Room ID
|
||||
report_id: Report ID to get markdown for
|
||||
|
||||
Returns:
|
||||
Markdown formatted report content
|
||||
"""
|
||||
report = (
|
||||
db.query(GeneratedReport)
|
||||
.filter(
|
||||
GeneratedReport.report_id == report_id,
|
||||
GeneratedReport.room_id == room_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not report:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Report not found",
|
||||
)
|
||||
|
||||
if report.status != ReportStatus.COMPLETED.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Report is not ready. Status: {report.status}",
|
||||
)
|
||||
|
||||
if not report.report_json:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Report content not found",
|
||||
)
|
||||
|
||||
# Collect room data for markdown generation
|
||||
data_service = ReportDataService(db)
|
||||
room_data = data_service.collect_room_data(room_id)
|
||||
|
||||
if not room_data:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Room not found",
|
||||
)
|
||||
|
||||
prompt_data = data_service.to_prompt_dict(room_data)
|
||||
|
||||
# Generate markdown
|
||||
markdown_content = docx_service.to_markdown(
|
||||
room_data=prompt_data["room_data"],
|
||||
ai_content=report.report_json,
|
||||
files=prompt_data["files"],
|
||||
)
|
||||
|
||||
return schemas.ReportMarkdownResponse(
|
||||
report_id=report.report_id,
|
||||
report_title=report.report_title,
|
||||
markdown=markdown_content,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{report_id}/download")
|
||||
async def download_report(
|
||||
room_id: str,
|
||||
@@ -262,11 +359,16 @@ async def download_report(
|
||||
filename = report.report_title or f"report_{report.report_id[:8]}"
|
||||
filename = f"{filename}.docx"
|
||||
|
||||
# Use RFC 5987 format for non-ASCII filenames
|
||||
# Provide ASCII fallback for older clients + UTF-8 encoded version
|
||||
ascii_filename = f"report_{report.report_id[:8]}.docx"
|
||||
encoded_filename = quote(filename)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(content),
|
||||
media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Disposition": f"attachment; filename=\"{ascii_filename}\"; filename*=UTF-8''{encoded_filename}",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -67,6 +67,13 @@ class ReportListResponse(BaseModel):
|
||||
total: int
|
||||
|
||||
|
||||
class ReportMarkdownResponse(BaseModel):
|
||||
"""Report content in Markdown format for in-page preview"""
|
||||
report_id: str
|
||||
report_title: Optional[str] = None
|
||||
markdown: str = Field(..., description="Full report content in Markdown format")
|
||||
|
||||
|
||||
# AI Report Content Schemas (validated JSON from DIFY)
|
||||
class TimelineEvent(BaseModel):
|
||||
"""Single event in timeline"""
|
||||
@@ -103,3 +110,10 @@ class AIReportContent(BaseModel):
|
||||
class ErrorResponse(BaseModel):
|
||||
"""Error response"""
|
||||
detail: str
|
||||
|
||||
|
||||
# Health Check Response
|
||||
class HealthCheckResponse(BaseModel):
|
||||
"""DIFY service health check response"""
|
||||
status: str = Field(..., description="Status: 'ok' or 'error'")
|
||||
message: str = Field(..., description="Human-readable status message")
|
||||
|
||||
@@ -252,6 +252,37 @@ class DifyService:
|
||||
"final_resolution section missing 'content' field when has_resolution is true"
|
||||
)
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""Test connection to DIFY API
|
||||
|
||||
Returns:
|
||||
Dict with status and message
|
||||
|
||||
Raises:
|
||||
DifyAPIError: If connection or API fails
|
||||
"""
|
||||
if not self.api_key:
|
||||
raise DifyAPIError("DIFY_API_KEY 未設定,請聯繫系統管理員")
|
||||
|
||||
# Send a simple test query
|
||||
url = f"{self.base_url}/parameters"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = await self._client.get(url, headers=headers)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "message": "AI 服務連線正常"}
|
||||
except httpx.TimeoutException:
|
||||
raise DifyAPIError("無法連接 AI 服務,請求逾時")
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise DifyAPIError("DIFY API Key 無效,請檢查設定")
|
||||
raise DifyAPIError(f"AI 服務回應錯誤: {e.response.status_code}")
|
||||
except httpx.RequestError as e:
|
||||
raise DifyAPIError(f"無法連接 AI 服務: {str(e)}")
|
||||
|
||||
async def close(self):
|
||||
"""Close HTTP client"""
|
||||
await self._client.aclose()
|
||||
|
||||
@@ -140,7 +140,7 @@ class DocxAssemblyService:
|
||||
|
||||
def _add_metadata_table(self, doc: Document, room_data: Dict[str, Any]):
|
||||
"""Add metadata summary table"""
|
||||
table = doc.add_table(rows=4, cols=4)
|
||||
table = doc.add_table(rows=5, cols=4)
|
||||
table.style = "Table Grid"
|
||||
|
||||
# Row 1: Type and Severity
|
||||
@@ -178,8 +178,15 @@ class DocxAssemblyService:
|
||||
else:
|
||||
cells[3].text = "尚未解決"
|
||||
|
||||
# Row 4: Description (spanning all columns)
|
||||
# Row 4: LOT batch numbers (spanning all columns)
|
||||
cells = table.rows[3].cells
|
||||
cells[0].text = "影響批號"
|
||||
cells[1].merge(cells[3])
|
||||
lots = room_data.get("lots", [])
|
||||
cells[1].text = ", ".join(lots) if lots else "無"
|
||||
|
||||
# Row 5: Description (spanning all columns)
|
||||
cells = table.rows[4].cells
|
||||
cells[0].text = "事件描述"
|
||||
# Merge remaining cells for description
|
||||
cells[1].merge(cells[3])
|
||||
@@ -401,6 +408,183 @@ class DocxAssemblyService:
|
||||
logger.error(f"Failed to download file from MinIO: {object_path} - {e}")
|
||||
return None
|
||||
|
||||
def to_markdown(
|
||||
self,
|
||||
room_data: Dict[str, Any],
|
||||
ai_content: Dict[str, Any],
|
||||
files: List[Dict[str, Any]] = None,
|
||||
) -> str:
|
||||
"""Convert report content to Markdown format
|
||||
|
||||
Args:
|
||||
room_data: Room metadata (title, type, severity, status, etc.)
|
||||
ai_content: AI-generated content (summary, timeline, participants, etc.)
|
||||
files: List of files with metadata (optional)
|
||||
|
||||
Returns:
|
||||
Markdown formatted string
|
||||
"""
|
||||
lines = []
|
||||
|
||||
# Title
|
||||
title = room_data.get("title", "未命名事件")
|
||||
lines.append(f"# 事件報告:{title}")
|
||||
lines.append("")
|
||||
|
||||
# Generation timestamp
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M")
|
||||
lines.append(f"*報告產生時間:{timestamp}*")
|
||||
lines.append("")
|
||||
|
||||
# Metadata section
|
||||
lines.append("## 基本資訊")
|
||||
lines.append("")
|
||||
lines.append("| 項目 | 內容 |")
|
||||
lines.append("|------|------|")
|
||||
|
||||
incident_type = room_data.get("incident_type", "other")
|
||||
lines.append(f"| 事件類型 | {self.INCIDENT_TYPE_MAP.get(incident_type, incident_type)} |")
|
||||
|
||||
severity = room_data.get("severity", "medium")
|
||||
lines.append(f"| 嚴重程度 | {self.SEVERITY_MAP.get(severity, severity)} |")
|
||||
|
||||
status = room_data.get("status", "active")
|
||||
lines.append(f"| 目前狀態 | {self.STATUS_MAP.get(status, status)} |")
|
||||
|
||||
lines.append(f"| 發生地點 | {room_data.get('location') or '未指定'} |")
|
||||
|
||||
created_at = room_data.get("created_at")
|
||||
if isinstance(created_at, datetime):
|
||||
lines.append(f"| 建立時間 | {created_at.strftime('%Y-%m-%d %H:%M')} |")
|
||||
else:
|
||||
lines.append(f"| 建立時間 | {str(created_at) if created_at else '未知'} |")
|
||||
|
||||
resolved_at = room_data.get("resolved_at")
|
||||
if isinstance(resolved_at, datetime):
|
||||
lines.append(f"| 解決時間 | {resolved_at.strftime('%Y-%m-%d %H:%M')} |")
|
||||
elif resolved_at:
|
||||
lines.append(f"| 解決時間 | {str(resolved_at)} |")
|
||||
else:
|
||||
lines.append("| 解決時間 | 尚未解決 |")
|
||||
|
||||
lots = room_data.get("lots", [])
|
||||
lines.append(f"| 影響批號 | {', '.join(lots) if lots else '無'} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# Description
|
||||
description = room_data.get("description")
|
||||
if description:
|
||||
lines.append("**事件描述:**")
|
||||
lines.append(f"> {description}")
|
||||
lines.append("")
|
||||
|
||||
# Summary section
|
||||
lines.append("## 事件摘要")
|
||||
lines.append("")
|
||||
summary = ai_content.get("summary", {})
|
||||
summary_content = summary.get("content", "無摘要內容")
|
||||
lines.append(summary_content)
|
||||
lines.append("")
|
||||
|
||||
# Timeline section
|
||||
lines.append("## 事件時間軸")
|
||||
lines.append("")
|
||||
timeline = ai_content.get("timeline", {})
|
||||
events = timeline.get("events", [])
|
||||
if events:
|
||||
lines.append("| 時間 | 事件 |")
|
||||
lines.append("|------|------|")
|
||||
for event in events:
|
||||
time = event.get("time", "")
|
||||
desc = event.get("description", "")
|
||||
# Escape pipe characters in content
|
||||
desc = desc.replace("|", "\\|")
|
||||
lines.append(f"| {time} | {desc} |")
|
||||
else:
|
||||
lines.append("無時間軸記錄")
|
||||
lines.append("")
|
||||
|
||||
# Participants section
|
||||
lines.append("## 參與人員")
|
||||
lines.append("")
|
||||
participants = ai_content.get("participants", {})
|
||||
members = participants.get("members", [])
|
||||
if members:
|
||||
lines.append("| 姓名 | 角色 |")
|
||||
lines.append("|------|------|")
|
||||
for member in members:
|
||||
name = member.get("name", "")
|
||||
role = member.get("role", "")
|
||||
lines.append(f"| {name} | {role} |")
|
||||
else:
|
||||
lines.append("無參與人員記錄")
|
||||
lines.append("")
|
||||
|
||||
# Resolution process section
|
||||
lines.append("## 處理過程")
|
||||
lines.append("")
|
||||
resolution = ai_content.get("resolution_process", {})
|
||||
resolution_content = resolution.get("content", "無處理過程記錄")
|
||||
lines.append(resolution_content)
|
||||
lines.append("")
|
||||
|
||||
# Current status section
|
||||
lines.append("## 目前狀態")
|
||||
lines.append("")
|
||||
current_status = ai_content.get("current_status", {})
|
||||
cs_status = current_status.get("status", "unknown")
|
||||
cs_text = self.STATUS_MAP.get(cs_status, cs_status)
|
||||
cs_description = current_status.get("description", "")
|
||||
lines.append(f"**狀態:** {cs_text}")
|
||||
if cs_description:
|
||||
lines.append("")
|
||||
lines.append(cs_description)
|
||||
lines.append("")
|
||||
|
||||
# Final resolution section
|
||||
lines.append("## 最終處置結果")
|
||||
lines.append("")
|
||||
final = ai_content.get("final_resolution", {})
|
||||
has_resolution = final.get("has_resolution", False)
|
||||
final_content = final.get("content", "")
|
||||
if has_resolution:
|
||||
if final_content:
|
||||
lines.append(final_content)
|
||||
else:
|
||||
lines.append("事件已解決,但無詳細說明。")
|
||||
else:
|
||||
lines.append("事件尚未解決或無最終處置結果。")
|
||||
lines.append("")
|
||||
|
||||
# File list section
|
||||
if files:
|
||||
lines.append("## 附件清單")
|
||||
lines.append("")
|
||||
lines.append("| 檔案名稱 | 類型 | 上傳者 | 上傳時間 |")
|
||||
lines.append("|----------|------|--------|----------|")
|
||||
|
||||
file_type_map = {
|
||||
"image": "圖片",
|
||||
"document": "文件",
|
||||
"log": "記錄檔",
|
||||
}
|
||||
|
||||
for f in files:
|
||||
filename = f.get("filename", "")
|
||||
file_type = f.get("file_type", "file")
|
||||
type_text = file_type_map.get(file_type, file_type)
|
||||
uploader = f.get("uploader_name") or f.get("uploader_id", "")
|
||||
uploaded_at = f.get("uploaded_at")
|
||||
if isinstance(uploaded_at, datetime):
|
||||
uploaded_text = uploaded_at.strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
uploaded_text = str(uploaded_at) if uploaded_at else ""
|
||||
lines.append(f"| {filename} | {type_text} | {uploader} | {uploaded_text} |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def upload_report(
|
||||
self,
|
||||
report_data: io.BytesIO,
|
||||
|
||||
@@ -62,6 +62,7 @@ class RoomReportData:
|
||||
location: Optional[str]
|
||||
description: Optional[str]
|
||||
resolution_notes: Optional[str]
|
||||
lots: List[str] # Affected LOT batch numbers
|
||||
created_at: datetime
|
||||
resolved_at: Optional[datetime]
|
||||
created_by: str
|
||||
@@ -111,6 +112,7 @@ class ReportDataService:
|
||||
location=room.location,
|
||||
description=room.description,
|
||||
resolution_notes=room.resolution_notes,
|
||||
lots=room.lots or [], # LOT batch numbers (JSON array)
|
||||
created_at=room.created_at,
|
||||
resolved_at=room.resolved_at,
|
||||
created_by=room.created_by,
|
||||
@@ -214,6 +216,7 @@ class ReportDataService:
|
||||
"location": data.location,
|
||||
"description": data.description,
|
||||
"resolution_notes": data.resolution_notes,
|
||||
"lots": data.lots, # Affected LOT batch numbers
|
||||
"created_at": data.created_at,
|
||||
"resolved_at": data.resolved_at,
|
||||
"created_by": data.created_by,
|
||||
|
||||
Reference in New Issue
Block a user