Complete implementation of the production line incident response system (生產線異常即時反應系統) including: Backend (FastAPI): - User authentication with AD integration and session management - Chat room management (create, list, update, members, roles) - Real-time messaging via WebSocket (typing indicators, reactions) - File storage with MinIO (upload, download, image preview) Frontend (React + Vite): - Authentication flow with token management - Room list with filtering, search, and pagination - Real-time chat interface with WebSocket - File upload with drag-and-drop and image preview - Member management and room settings - Breadcrumb navigation - 53 unit tests (Vitest) Specifications: - authentication: AD auth, sessions, JWT tokens - chat-room: rooms, members, templates - realtime-messaging: WebSocket, messages, reactions - file-storage: MinIO integration, file management - frontend-core: React SPA structure 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
229 lines
8.1 KiB
Python
229 lines
8.1 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
|
|
|
|
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)
|
|
|
|
# Broadcast file upload event to room members via WebSocket
|
|
async def broadcast_file_upload():
|
|
try:
|
|
broadcast = FileUploadedBroadcast(
|
|
file_id=result.file_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,
|
|
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}")
|
|
|
|
# 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(asyncio.create_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
|
|
)
|
|
|
|
# Build response with download URL
|
|
return FileMetadata(
|
|
file_id=file_record.file_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
|
|
)
|
|
|
|
|
|
@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)
|
|
deleted_file = FileService.delete_file(db, file_id, user_email, is_room_owner or is_admin)
|
|
|
|
# Broadcast file deletion event to room members via WebSocket
|
|
if deleted_file:
|
|
async def broadcast_file_delete():
|
|
try:
|
|
broadcast = FileDeletedBroadcast(
|
|
file_id=file_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())
|
|
logger.info(f"Broadcasted file deletion event: {file_id} from room {room_id}")
|
|
except Exception as e:
|
|
logger.error(f"Failed to broadcast file deletion: {e}")
|
|
|
|
# Run broadcast in background
|
|
background_tasks.add_task(asyncio.create_task, broadcast_file_delete())
|
|
|
|
return None
|