feat: Initial commit - Task Reporter incident response system
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>
This commit is contained in:
228
app/modules/file_storage/router.py
Normal file
228
app/modules/file_storage/router.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user