Files
egg 44822a561a 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>
2025-12-08 12:39:15 +08:00

290 lines
11 KiB
Python

"""API routes for file storage operations
FastAPI router with file upload, download, listing, and delete endpoints
"""
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, Query, status, BackgroundTasks
from sqlalchemy.orm import Session
from typing import Optional
from datetime import datetime
import asyncio
import logging
from app.core.database import get_db
from app.core.config import get_settings
from app.modules.auth import get_current_user
from app.modules.chat_room.dependencies import get_current_room
from app.modules.chat_room.models import MemberRole
from app.modules.chat_room.services.membership_service import membership_service
from app.modules.file_storage.schemas import FileUploadResponse, FileMetadata, FileListResponse, FileType
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, MessageDeletedBroadcast, MessageBroadcast, MessageTypeEnum
from app.modules.realtime.services.message_service import MessageService
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/rooms", tags=["Files"])
@router.post("/{room_id}/files", response_model=FileUploadResponse, status_code=status.HTTP_201_CREATED)
async def upload_file(
room_id: str,
background_tasks: BackgroundTasks,
file: UploadFile = File(...),
description: Optional[str] = Form(None),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Upload a file to an incident room
Requires OWNER or EDITOR role in the room.
Supported file types:
- Images: jpg, jpeg, png, gif (max 10MB)
- Documents: pdf (max 20MB)
- Logs: txt, log, csv (max 5MB)
"""
user_email = current_user["username"]
# Check write permission (OWNER or EDITOR)
member = FileService.check_room_membership(db, room_id, user_email)
if not FileService.check_write_permission(member):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only OWNER or EDITOR can upload files"
)
# 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,
file_type=result.file_type.value,
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} (message: {result.message_id}) to room {room_id}")
# Send acknowledgment to uploader
ack = FileUploadAck(
file_id=result.file_id,
status="success",
download_url=result.download_url
)
await websocket_manager.send_personal(user_email, ack.to_dict())
except Exception as e:
logger.error(f"Failed to broadcast file upload: {e}")
# Run broadcast in background
background_tasks.add_task(broadcast_file_upload)
return result
@router.get("/{room_id}/files", response_model=FileListResponse)
async def list_files(
room_id: str,
file_type: Optional[FileType] = Query(None, description="Filter by file type"),
limit: int = Query(50, ge=1, le=100, description="Number of files to return"),
offset: int = Query(0, ge=0, description="Number of files to skip"),
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""List files in an incident room with pagination
All room members can list files.
"""
# Convert enum to string value if provided
file_type_str = file_type.value if file_type else None
return FileService.get_files(db, room_id, limit, offset, file_type_str)
@router.get("/{room_id}/files/{file_id}", response_model=FileMetadata)
async def get_file(
room_id: str,
file_id: str,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Get file metadata and presigned download URL
All room members can access file metadata and download files.
Presigned URL expires in 1 hour.
"""
settings = get_settings()
# Get file metadata
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Generate presigned download URL
download_url = minio_service.generate_presigned_url(
bucket=settings.MINIO_BUCKET,
object_path=file_record.minio_object_path,
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,
mime_type=file_record.mime_type,
file_size=file_record.file_size,
minio_bucket=file_record.minio_bucket,
minio_object_path=file_record.minio_object_path,
uploaded_at=file_record.uploaded_at,
uploader_id=file_record.uploader_id,
deleted_at=file_record.deleted_at,
download_url=download_url,
thumbnail_url=thumbnail_url
)
@router.delete("/{room_id}/files/{file_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_file(
room_id: str,
file_id: str,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
current_user: dict = Depends(get_current_user),
_room = Depends(get_current_room) # Validates room exists and user has access
):
"""Soft delete a file
Only the file uploader or room OWNER can delete files.
"""
user_email = current_user["username"]
# Get file to check ownership
file_record = FileService.get_file(db, file_id)
if not file_record:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found"
)
# Verify file belongs to requested room
if file_record.room_id != room_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="File not found in this room"
)
# Check if user is room owner
role = membership_service.get_user_role_in_room(db, room_id, user_email)
is_room_owner = role == MemberRole.OWNER
# Check if admin
is_admin = membership_service.is_system_admin(user_email)
# 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 and message deletion events to room members via WebSocket
if deleted_file:
async def broadcast_file_delete():
try:
# 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, 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/message deletion: {e}")
# Run broadcast in background
background_tasks.add_task(broadcast_file_delete)
return None