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>
290 lines
11 KiB
Python
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
|