"""Attachments API endpoints.""" import os import uuid from pathlib import Path from typing import Annotated from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from fastapi.responses import FileResponse from sqlalchemy.orm import Session, joinedload from app.core.deps import get_current_user, get_db from app.models.attachment import Attachment from app.models.booking import Booking from app.models.user import User from app.schemas.attachment import AttachmentRead router = APIRouter() UPLOAD_DIR = Path(__file__).parent.parent.parent / "uploads" UPLOAD_DIR.mkdir(exist_ok=True) MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB MAX_FILES_PER_BOOKING = 5 ALLOWED_EXTENSIONS = {".pdf", ".docx", ".pptx", ".xlsx", ".xls", ".png", ".jpg", ".jpeg"} @router.post("/bookings/{booking_id}/attachments", response_model=AttachmentRead, status_code=201) async def upload_attachment( booking_id: int, file: Annotated[UploadFile, File()], db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> AttachmentRead: """Upload file attachment to booking.""" # Check booking exists and user is owner booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail="Booking not found") if booking.user_id != current_user.id and current_user.role != "admin": raise HTTPException(status_code=403, detail="Can only attach files to your own bookings") # Check max files limit existing_count = db.query(Attachment).filter(Attachment.booking_id == booking_id).count() if existing_count >= MAX_FILES_PER_BOOKING: raise HTTPException( status_code=400, detail=f"Maximum {MAX_FILES_PER_BOOKING} files per booking" ) # Validate file extension if not file.filename: raise HTTPException(status_code=400, detail="Filename is required") file_ext = Path(file.filename).suffix.lower() if file_ext not in ALLOWED_EXTENSIONS: raise HTTPException( status_code=400, detail=f"File type not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}" ) # Read file content = await file.read() file_size = len(content) # Check file size if file_size > MAX_FILE_SIZE: raise HTTPException( status_code=400, detail=f"File too large. Max size: {MAX_FILE_SIZE // (1024*1024)}MB" ) # Generate unique filename unique_filename = f"{uuid.uuid4()}{file_ext}" filepath = UPLOAD_DIR / unique_filename # Save file with open(filepath, "wb") as f: f.write(content) # Create attachment record attachment = Attachment( booking_id=booking_id, filename=file.filename, stored_filename=unique_filename, filepath=str(filepath), size=file_size, content_type=file.content_type or "application/octet-stream", uploaded_by=current_user.id, ) db.add(attachment) db.commit() db.refresh(attachment) return AttachmentRead( id=attachment.id, booking_id=attachment.booking_id, filename=attachment.filename, size=attachment.size, content_type=attachment.content_type, uploaded_by=attachment.uploaded_by, uploader_name=current_user.full_name, created_at=attachment.created_at, ) @router.get("/bookings/{booking_id}/attachments", response_model=list[AttachmentRead]) def list_attachments( booking_id: int, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> list[AttachmentRead]: """List all attachments for a booking.""" booking = db.query(Booking).filter(Booking.id == booking_id).first() if not booking: raise HTTPException(status_code=404, detail="Booking not found") attachments = ( db.query(Attachment) .options(joinedload(Attachment.uploader)) .filter(Attachment.booking_id == booking_id) .all() ) return [ AttachmentRead( id=a.id, booking_id=a.booking_id, filename=a.filename, size=a.size, content_type=a.content_type, uploaded_by=a.uploaded_by, uploader_name=a.uploader.full_name, created_at=a.created_at, ) for a in attachments ] @router.get("/attachments/{attachment_id}/download") def download_attachment( attachment_id: int, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> FileResponse: """Download attachment file.""" attachment = ( db.query(Attachment) .options(joinedload(Attachment.booking)) .filter(Attachment.id == attachment_id) .first() ) if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Check if user can access (owner or admin) if attachment.booking.user_id != current_user.id and current_user.role != "admin": raise HTTPException(status_code=403, detail="Cannot access this attachment") # Return file return FileResponse( path=attachment.filepath, filename=attachment.filename, media_type=attachment.content_type ) @router.delete("/attachments/{attachment_id}", status_code=204) def delete_attachment( attachment_id: int, db: Annotated[Session, Depends(get_db)], current_user: Annotated[User, Depends(get_current_user)], ) -> None: """Delete attachment.""" attachment = ( db.query(Attachment) .options(joinedload(Attachment.booking)) .filter(Attachment.id == attachment_id) .first() ) if not attachment: raise HTTPException(status_code=404, detail="Attachment not found") # Check permission if attachment.uploaded_by != current_user.id and current_user.role != "admin": raise HTTPException(status_code=403, detail="Can only delete your own attachments") # Delete file from disk if os.path.exists(attachment.filepath): os.remove(attachment.filepath) # Delete record db.delete(attachment) db.commit()