"""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