feat: Improve file display, timezone handling, and LOT management
Changes: - Fix datetime serialization with UTC 'Z' suffix for correct timezone display - Add PDF upload support with extension fallback for MIME detection - Fix LOT add/remove by creating new list for SQLAlchemy JSON change detection - Add file message components (FileMessage, ImageLightbox, UploadPreview) - Add multi-file upload support with progress tracking - Link uploaded files to chat messages via message_id - Include file attachments in AI report generation - Update specs for file-storage, realtime-messaging, and ai-report-generation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,9 @@ class RoomFile(Base):
|
||||
# Foreign key to incident room (CASCADE delete when room is permanently deleted)
|
||||
room_id = Column(String(36), ForeignKey("tr_incident_rooms.room_id", ondelete="CASCADE"), nullable=False)
|
||||
|
||||
# Foreign key to associated message (nullable for legacy files)
|
||||
message_id = Column(String(36), ForeignKey("tr_messages.message_id", ondelete="SET NULL"), nullable=True)
|
||||
|
||||
# File metadata
|
||||
uploader_id = Column(String(255), nullable=False)
|
||||
filename = Column(String(255), nullable=False)
|
||||
@@ -33,11 +36,13 @@ class RoomFile(Base):
|
||||
|
||||
# Relationships
|
||||
room = relationship("IncidentRoom", back_populates="files")
|
||||
message = relationship("Message", backref="file_attachment", uselist=False)
|
||||
|
||||
# Indexes
|
||||
__table_args__ = (
|
||||
Index("ix_tr_room_files_room_uploaded", "room_id", "uploaded_at"),
|
||||
Index("ix_tr_room_files_uploader", "uploader_id"),
|
||||
Index("ix_tr_room_files_message", "message_id"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -19,7 +19,8 @@ from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, F
|
||||
from app.modules.file_storage.services.file_service import FileService
|
||||
from app.modules.file_storage.services import minio_service
|
||||
from app.modules.realtime.websocket_manager import manager as websocket_manager
|
||||
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck
|
||||
from app.modules.realtime.schemas import FileUploadedBroadcast, FileDeletedBroadcast, FileUploadAck, MessageDeletedBroadcast, MessageBroadcast, MessageTypeEnum
|
||||
from app.modules.realtime.services.message_service import MessageService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -58,11 +59,52 @@ async def upload_file(
|
||||
# Upload file
|
||||
result = FileService.upload_file(db, room_id, user_email, file, description)
|
||||
|
||||
# Fetch the message and display name for broadcasting (before background task)
|
||||
message_obj = MessageService.get_message(db, result.message_id) if result.message_id else None
|
||||
display_name = MessageService.get_display_name(db, user_email)
|
||||
|
||||
# Prepare message broadcast data (needed before db session closes)
|
||||
message_data = None
|
||||
if message_obj:
|
||||
message_data = {
|
||||
"message_id": message_obj.message_id,
|
||||
"room_id": message_obj.room_id,
|
||||
"sender_id": message_obj.sender_id,
|
||||
"sender_display_name": display_name,
|
||||
"content": message_obj.content,
|
||||
"message_type": message_obj.message_type.value,
|
||||
"metadata": message_obj.message_metadata,
|
||||
"created_at": message_obj.created_at,
|
||||
"sequence_number": message_obj.sequence_number,
|
||||
}
|
||||
|
||||
# Broadcast file upload event to room members via WebSocket
|
||||
async def broadcast_file_upload():
|
||||
try:
|
||||
# First, broadcast the message event so it appears in chat
|
||||
if message_data:
|
||||
logger.info(f"Broadcasting message for file upload. message_data: {message_data}")
|
||||
msg_broadcast = MessageBroadcast(
|
||||
type="message",
|
||||
message_id=message_data["message_id"],
|
||||
room_id=message_data["room_id"],
|
||||
sender_id=message_data["sender_id"],
|
||||
sender_display_name=message_data["sender_display_name"],
|
||||
content=message_data["content"],
|
||||
message_type=MessageTypeEnum(message_data["message_type"]),
|
||||
metadata=message_data["metadata"],
|
||||
created_at=message_data["created_at"],
|
||||
sequence_number=message_data["sequence_number"],
|
||||
)
|
||||
broadcast_dict = msg_broadcast.model_dump(mode='json')
|
||||
logger.info(f"Message broadcast dict: {broadcast_dict}")
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast_dict)
|
||||
logger.info(f"Broadcasted file message: {message_data['message_id']} to room {room_id}")
|
||||
|
||||
# Then broadcast file uploaded event (for file drawer updates)
|
||||
broadcast = FileUploadedBroadcast(
|
||||
file_id=result.file_id,
|
||||
message_id=result.message_id,
|
||||
room_id=room_id,
|
||||
uploader_id=user_email,
|
||||
filename=result.filename,
|
||||
@@ -70,10 +112,11 @@ async def upload_file(
|
||||
file_size=result.file_size,
|
||||
mime_type=result.mime_type,
|
||||
download_url=result.download_url,
|
||||
thumbnail_url=result.thumbnail_url,
|
||||
uploaded_at=result.uploaded_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
|
||||
logger.info(f"Broadcasted file upload event: {result.file_id} to room {room_id}")
|
||||
logger.info(f"Broadcasted file upload event: {result.file_id} (message: {result.message_id}) to room {room_id}")
|
||||
|
||||
# Send acknowledgment to uploader
|
||||
ack = FileUploadAck(
|
||||
@@ -86,7 +129,7 @@ async def upload_file(
|
||||
logger.error(f"Failed to broadcast file upload: {e}")
|
||||
|
||||
# Run broadcast in background
|
||||
background_tasks.add_task(asyncio.create_task, broadcast_file_upload())
|
||||
background_tasks.add_task(broadcast_file_upload)
|
||||
|
||||
return result
|
||||
|
||||
@@ -149,9 +192,13 @@ async def get_file(
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
|
||||
thumbnail_url = download_url if file_record.file_type == "image" else None
|
||||
|
||||
# Build response with download URL
|
||||
return FileMetadata(
|
||||
file_id=file_record.file_id,
|
||||
message_id=file_record.message_id,
|
||||
room_id=file_record.room_id,
|
||||
filename=file_record.filename,
|
||||
file_type=file_record.file_type,
|
||||
@@ -162,7 +209,8 @@ async def get_file(
|
||||
uploaded_at=file_record.uploaded_at,
|
||||
uploader_id=file_record.uploader_id,
|
||||
deleted_at=file_record.deleted_at,
|
||||
download_url=download_url
|
||||
download_url=download_url,
|
||||
thumbnail_url=thumbnail_url
|
||||
)
|
||||
|
||||
|
||||
@@ -204,25 +252,38 @@ async def delete_file(
|
||||
# Check if admin
|
||||
is_admin = membership_service.is_system_admin(user_email)
|
||||
|
||||
# Delete file (service will verify permissions)
|
||||
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
|
||||
# Delete file (service will verify permissions and cascade to message)
|
||||
deleted_file, deleted_message_id = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
|
||||
|
||||
# Broadcast file deletion event to room members via WebSocket
|
||||
# Broadcast file and message deletion events to room members via WebSocket
|
||||
if deleted_file:
|
||||
async def broadcast_file_delete():
|
||||
try:
|
||||
broadcast = FileDeletedBroadcast(
|
||||
# Broadcast file deleted event
|
||||
file_broadcast = FileDeletedBroadcast(
|
||||
file_id=file_id,
|
||||
message_id=deleted_message_id,
|
||||
room_id=room_id,
|
||||
deleted_by=user_email,
|
||||
deleted_at=deleted_file.deleted_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, broadcast.to_dict())
|
||||
await websocket_manager.broadcast_to_room(room_id, file_broadcast.to_dict())
|
||||
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
|
||||
|
||||
# Also broadcast message deleted event if there was an associated message
|
||||
if deleted_message_id:
|
||||
msg_broadcast = MessageDeletedBroadcast(
|
||||
message_id=deleted_message_id,
|
||||
room_id=room_id,
|
||||
deleted_by=user_email,
|
||||
deleted_at=deleted_file.deleted_at
|
||||
)
|
||||
await websocket_manager.broadcast_to_room(room_id, msg_broadcast.to_dict())
|
||||
logger.info(f"Broadcasted message deletion event: {deleted_message_id} from room {room_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to broadcast file deletion: {e}")
|
||||
logger.error(f"Failed to broadcast file/message deletion: {e}")
|
||||
|
||||
# Run broadcast in background
|
||||
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
|
||||
background_tasks.add_task(broadcast_file_delete)
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Pydantic schemas for file storage operations"""
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator, field_serializer, ConfigDict
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
@@ -15,21 +15,28 @@ class FileType(str, Enum):
|
||||
class FileUploadResponse(BaseModel):
|
||||
"""Response after successful file upload"""
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
filename: str
|
||||
file_type: FileType
|
||||
file_size: int
|
||||
mime_type: str
|
||||
download_url: str # Presigned URL
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
uploaded_at: datetime
|
||||
uploader_id: str
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_serializer("uploaded_at")
|
||||
def serialize_datetime(self, dt: datetime) -> str:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class FileMetadata(BaseModel):
|
||||
"""File metadata response"""
|
||||
file_id: str
|
||||
message_id: Optional[str] = None # Associated chat message ID
|
||||
room_id: str
|
||||
filename: str
|
||||
file_type: FileType
|
||||
@@ -41,9 +48,9 @@ class FileMetadata(BaseModel):
|
||||
uploader_id: str
|
||||
deleted_at: Optional[datetime] = None
|
||||
download_url: Optional[str] = None # Presigned URL (only when requested)
|
||||
thumbnail_url: Optional[str] = None # Thumbnail URL for images
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@field_validator("file_size")
|
||||
@classmethod
|
||||
@@ -53,6 +60,13 @@ class FileMetadata(BaseModel):
|
||||
raise ValueError("File size must be positive")
|
||||
return v
|
||||
|
||||
@field_serializer("uploaded_at", "deleted_at")
|
||||
def serialize_datetime(self, dt: Optional[datetime]) -> Optional[str]:
|
||||
"""Serialize datetime with 'Z' suffix to indicate UTC"""
|
||||
if dt is None:
|
||||
return None
|
||||
return dt.isoformat() + "Z"
|
||||
|
||||
|
||||
class FileListResponse(BaseModel):
|
||||
"""Paginated file list response"""
|
||||
@@ -62,13 +76,11 @@ class FileListResponse(BaseModel):
|
||||
offset: int
|
||||
has_more: bool
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class FileUploadParams(BaseModel):
|
||||
"""Parameters for file upload (optional description)"""
|
||||
description: Optional[str] = Field(None, max_length=500)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
@@ -69,11 +69,38 @@ class FileService:
|
||||
detail="File storage service temporarily unavailable"
|
||||
)
|
||||
|
||||
# Create database record
|
||||
# Create database record and associated message
|
||||
try:
|
||||
# Generate presigned download URL
|
||||
download_url = minio_service.generate_presigned_url(
|
||||
bucket=settings.MINIO_BUCKET,
|
||||
object_path=object_path,
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
# For images, the download URL also serves as thumbnail (CSS resized on frontend)
|
||||
thumbnail_url = download_url if file_type == "image" else None
|
||||
|
||||
# Create the associated chat message first
|
||||
message = FileService.create_file_reference_message(
|
||||
db=db,
|
||||
room_id=room_id,
|
||||
sender_id=uploader_id,
|
||||
file_id=file_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
mime_type=mime_type,
|
||||
file_size=file_size,
|
||||
file_url=download_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
description=description
|
||||
)
|
||||
|
||||
# Create file record with message_id reference
|
||||
room_file = RoomFile(
|
||||
file_id=file_id,
|
||||
room_id=room_id,
|
||||
message_id=message.message_id,
|
||||
uploader_id=uploader_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
@@ -88,20 +115,15 @@ class FileService:
|
||||
db.commit()
|
||||
db.refresh(room_file)
|
||||
|
||||
# Generate presigned download URL
|
||||
download_url = minio_service.generate_presigned_url(
|
||||
bucket=settings.MINIO_BUCKET,
|
||||
object_path=object_path,
|
||||
expiry_seconds=3600
|
||||
)
|
||||
|
||||
return FileUploadResponse(
|
||||
file_id=file_id,
|
||||
message_id=message.message_id,
|
||||
filename=file.filename,
|
||||
file_type=file_type,
|
||||
file_size=file_size,
|
||||
mime_type=mime_type,
|
||||
download_url=download_url,
|
||||
thumbnail_url=thumbnail_url,
|
||||
uploaded_at=room_file.uploaded_at,
|
||||
uploader_id=uploader_id
|
||||
)
|
||||
@@ -160,12 +182,17 @@ class FileService:
|
||||
file_id: str,
|
||||
user_id: str,
|
||||
is_room_owner: bool = False
|
||||
) -> Optional[RoomFile]:
|
||||
"""Soft delete file"""
|
||||
) -> tuple[Optional[RoomFile], Optional[str]]:
|
||||
"""
|
||||
Soft delete file and its associated message.
|
||||
|
||||
Returns:
|
||||
Tuple of (deleted_file, deleted_message_id) or (None, None) if not found
|
||||
"""
|
||||
file = db.query(RoomFile).filter(RoomFile.file_id == file_id).first()
|
||||
|
||||
if not file:
|
||||
return None
|
||||
return None, None
|
||||
|
||||
# Check permissions
|
||||
if not is_room_owner and file.uploader_id != user_id:
|
||||
@@ -174,12 +201,21 @@ class FileService:
|
||||
detail="Only file uploader or room owner can delete files"
|
||||
)
|
||||
|
||||
# Soft delete
|
||||
deleted_message_id = None
|
||||
|
||||
# Soft delete the associated message if it exists
|
||||
if file.message_id:
|
||||
message = db.query(Message).filter(Message.message_id == file.message_id).first()
|
||||
if message and message.deleted_at is None:
|
||||
message.deleted_at = datetime.utcnow()
|
||||
deleted_message_id = message.message_id
|
||||
|
||||
# Soft delete the file
|
||||
file.deleted_at = datetime.utcnow()
|
||||
db.commit()
|
||||
db.refresh(file)
|
||||
|
||||
return file
|
||||
return file, deleted_message_id
|
||||
|
||||
@staticmethod
|
||||
def check_room_membership(db: Session, room_id: str, user_id: str) -> Optional[RoomMember]:
|
||||
@@ -205,7 +241,10 @@ class FileService:
|
||||
file_id: str,
|
||||
filename: str,
|
||||
file_type: str,
|
||||
mime_type: str,
|
||||
file_size: int,
|
||||
file_url: str,
|
||||
thumbnail_url: Optional[str] = None,
|
||||
description: Optional[str] = None
|
||||
) -> Message:
|
||||
"""
|
||||
@@ -218,7 +257,10 @@ class FileService:
|
||||
file_id: File ID in room_files table
|
||||
filename: Original filename
|
||||
file_type: Type of file (image, document, log)
|
||||
mime_type: MIME type of the file
|
||||
file_size: File size in bytes
|
||||
file_url: Presigned download URL
|
||||
thumbnail_url: Presigned thumbnail URL for images
|
||||
description: Optional description for the file
|
||||
|
||||
Returns:
|
||||
@@ -237,9 +279,15 @@ class FileService:
|
||||
"file_id": file_id,
|
||||
"file_url": file_url,
|
||||
"filename": filename,
|
||||
"file_type": file_type
|
||||
"file_type": file_type,
|
||||
"mime_type": mime_type,
|
||||
"file_size": file_size
|
||||
}
|
||||
|
||||
# Add thumbnail URL for images
|
||||
if thumbnail_url:
|
||||
metadata["thumbnail_url"] = thumbnail_url
|
||||
|
||||
# Use MessageService to create the message
|
||||
return MessageService.create_message(
|
||||
db=db,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""File validation utilities"""
|
||||
import magic
|
||||
import os
|
||||
from fastapi import UploadFile, HTTPException
|
||||
from typing import Set
|
||||
from typing import Set, Dict
|
||||
import logging
|
||||
|
||||
from app.core.config import get_settings
|
||||
@@ -17,7 +18,15 @@ IMAGE_TYPES: Set[str] = {
|
||||
}
|
||||
|
||||
DOCUMENT_TYPES: Set[str] = {
|
||||
"application/pdf"
|
||||
"application/pdf",
|
||||
"application/x-pdf", # Some systems detect PDF as x-pdf
|
||||
}
|
||||
|
||||
# Extensions that can be accepted even if MIME detection fails
|
||||
EXTENSION_FALLBACK: Dict[str, str] = {
|
||||
".pdf": "application/pdf",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
}
|
||||
|
||||
LOG_TYPES: Set[str] = {
|
||||
@@ -67,6 +76,17 @@ def validate_file_type(file: UploadFile, allowed_types: Set[str]) -> str:
|
||||
detected_mime = detect_mime_type(header)
|
||||
|
||||
if detected_mime not in allowed_types:
|
||||
# Try extension fallback for known safe file types
|
||||
filename = file.filename or ""
|
||||
_, ext = os.path.splitext(filename.lower())
|
||||
|
||||
if ext in EXTENSION_FALLBACK:
|
||||
logger.info(
|
||||
f"MIME detection returned {detected_mime} for {filename}, "
|
||||
f"using extension fallback: {EXTENSION_FALLBACK[ext]}"
|
||||
)
|
||||
return EXTENSION_FALLBACK[ext]
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"File type not allowed: {detected_mime}. Allowed types: {', '.join(allowed_types)}"
|
||||
@@ -115,9 +135,12 @@ def get_file_type_and_limits(mime_type: str) -> tuple[str, int]:
|
||||
Raises:
|
||||
HTTPException if MIME type not recognized
|
||||
"""
|
||||
# Include extension fallback types as documents
|
||||
document_types_extended = DOCUMENT_TYPES | set(EXTENSION_FALLBACK.values())
|
||||
|
||||
if mime_type in IMAGE_TYPES:
|
||||
return ("image", settings.get_image_max_size_bytes())
|
||||
elif mime_type in DOCUMENT_TYPES:
|
||||
elif mime_type in document_types_extended:
|
||||
return ("document", settings.get_document_max_size_bytes())
|
||||
elif mime_type in LOG_TYPES:
|
||||
return ("log", settings.get_log_max_size_bytes())
|
||||
|
||||
Reference in New Issue
Block a user