|
| 1 | +""" |
| 2 | +Attachments Router - API Endpoints for File Form Fields |
| 3 | +
|
| 4 | +Provides REST API for: |
| 5 | +- File upload |
| 6 | +- File retrieval |
| 7 | +- File deletion |
| 8 | +""" |
| 9 | + |
| 10 | +import os |
| 11 | +import asyncio |
| 12 | +import shutil |
| 13 | +import uuid |
| 14 | +import logging |
| 15 | +from pathlib import Path |
| 16 | +from typing import Dict, Any, Optional |
| 17 | + |
| 18 | +from fastapi import APIRouter, File, UploadFile, HTTPException, BackgroundTasks |
| 19 | +from fastapi.responses import FileResponse, JSONResponse |
| 20 | +from pydantic import BaseModel |
| 21 | + |
| 22 | +from utils.logging import get_logger |
| 23 | + |
| 24 | +logger = get_logger(__name__) |
| 25 | + |
| 26 | +router = APIRouter(prefix="/attachments", tags=["Attachments"]) |
| 27 | + |
| 28 | +# ============================================================================= |
| 29 | +# Persistent Disk Storage |
| 30 | +# ============================================================================= |
| 31 | + |
| 32 | +STORAGE_DIR = Path("storage") |
| 33 | +ATTACHMENTS_DIR = STORAGE_DIR / "attachments" |
| 34 | + |
| 35 | +# Ensure directories exist |
| 36 | +ATTACHMENTS_DIR.mkdir(parents=True, exist_ok=True) |
| 37 | + |
| 38 | + |
| 39 | +# ============================================================================= |
| 40 | +# Request/Response Models |
| 41 | +# ============================================================================= |
| 42 | + |
| 43 | +class AttachmentUploadResponse(BaseModel): |
| 44 | + """Response from file upload.""" |
| 45 | + success: bool |
| 46 | + file_id: str |
| 47 | + file_name: str |
| 48 | + content_type: str |
| 49 | + size: int |
| 50 | + url: str |
| 51 | + message: str = "" |
| 52 | + |
| 53 | + |
| 54 | +# ============================================================================= |
| 55 | +# Helper Functions |
| 56 | +# ============================================================================= |
| 57 | + |
| 58 | +def _get_file_path(file_id: str) -> Path: |
| 59 | + """Get the file path for a given file ID.""" |
| 60 | + # We search for any file starting with file_id to handle extensions |
| 61 | + # But for simplicity, we will save files with their original extension appended to ID or keep a metadata map. |
| 62 | + # A simpler approach: save as `{file_id}_{filename}` to preserve extension and name. |
| 63 | + # However, to easily lookup by ID, we might just use ID and a sidecar metadata file, |
| 64 | + # OR scan the directory (slower). |
| 65 | + # |
| 66 | + # Improved approach: Save as `file_id` (content) and `file_id.json` (metadata). |
| 67 | + return ATTACHMENTS_DIR / file_id |
| 68 | + |
| 69 | + |
| 70 | +def _save_attachment(file_id: str, file: UploadFile) -> Path: |
| 71 | + """Save uploaded file to disk.""" |
| 72 | + file_path = ATTACHMENTS_DIR / file_id |
| 73 | + |
| 74 | + # Save content |
| 75 | + try: |
| 76 | + with file_path.open("wb") as buffer: |
| 77 | + shutil.copyfileobj(file.file, buffer) |
| 78 | + except Exception as e: |
| 79 | + logger.error(f"Failed to write file {file_id}: {e}") |
| 80 | + raise HTTPException(status_code=500, detail="Failed to save file to storage") |
| 81 | + |
| 82 | + return file_path |
| 83 | + |
| 84 | + |
| 85 | +def _save_metadata(file_id: str, metadata: Dict[str, Any]): |
| 86 | + """Save metadata to disk.""" |
| 87 | + meta_path = ATTACHMENTS_DIR / f"{file_id}.json" |
| 88 | + import json |
| 89 | + with open(meta_path, "w") as f: |
| 90 | + json.dump(metadata, f) |
| 91 | + |
| 92 | + |
| 93 | +def _get_metadata(file_id: str) -> Optional[Dict[str, Any]]: |
| 94 | + """Retrieve metadata from disk.""" |
| 95 | + meta_path = ATTACHMENTS_DIR / f"{file_id}.json" |
| 96 | + |
| 97 | + if not meta_path.exists(): |
| 98 | + return None |
| 99 | + |
| 100 | + import json |
| 101 | + try: |
| 102 | + with open(meta_path, "r") as f: |
| 103 | + return json.load(f) |
| 104 | + except Exception as e: |
| 105 | + logger.error(f"Failed to read metadata for {file_id}: {e}") |
| 106 | + return None |
| 107 | + |
| 108 | + |
| 109 | +async def _cleanup_attachment(file_id: str): |
| 110 | + """Remove attachment from storage after timeout (e.g. 24 hours).""" |
| 111 | + try: |
| 112 | + await asyncio.sleep(86400) # 24 hours |
| 113 | + except asyncio.CancelledError: |
| 114 | + return |
| 115 | + |
| 116 | + try: |
| 117 | + logger.info(f"🧹 Cleaning up attachment {file_id}") |
| 118 | + file_path = ATTACHMENTS_DIR / file_id |
| 119 | + meta_path = ATTACHMENTS_DIR / f"{file_id}.json" |
| 120 | + |
| 121 | + file_path.unlink(missing_ok=True) |
| 122 | + meta_path.unlink(missing_ok=True) |
| 123 | + except Exception as e: |
| 124 | + logger.warning(f"Cleanup failed for {file_id}: {e}") |
| 125 | + |
| 126 | + |
| 127 | +# ============================================================================= |
| 128 | +# Endpoints |
| 129 | +# ============================================================================= |
| 130 | + |
| 131 | +@router.post("/upload", response_model=AttachmentUploadResponse) |
| 132 | +async def upload_attachment( |
| 133 | + file: UploadFile = File(...), |
| 134 | + background_tasks: BackgroundTasks = None, |
| 135 | +): |
| 136 | + """ |
| 137 | + Upload a file for a form attachment field. |
| 138 | + |
| 139 | + Returns file ID and URL for retrieval. |
| 140 | + """ |
| 141 | + if not file: |
| 142 | + raise HTTPException(status_code=400, detail="No file provided") |
| 143 | + |
| 144 | + file_id = str(uuid.uuid4()) |
| 145 | + logger.info(f"📂 Uploading attachment: {file.filename} (ID: {file_id})") |
| 146 | + |
| 147 | + try: |
| 148 | + # Save file to disk |
| 149 | + file_path = _save_attachment(file_id, file) |
| 150 | + file_size = file_path.stat().st_size |
| 151 | + |
| 152 | + # Save metadata |
| 153 | + metadata = { |
| 154 | + "id": file_id, |
| 155 | + "original_filename": file.filename, |
| 156 | + "content_type": file.content_type, |
| 157 | + "size": file_size, |
| 158 | + "upload_time": str(uuid.uuid1().time), # simple timestamp proxy |
| 159 | + } |
| 160 | + _save_metadata(file_id, metadata) |
| 161 | + |
| 162 | + # Schedule cleanup |
| 163 | + if background_tasks: |
| 164 | + background_tasks.add_task(_cleanup_attachment, file_id) |
| 165 | + |
| 166 | + return AttachmentUploadResponse( |
| 167 | + success=True, |
| 168 | + file_id=file_id, |
| 169 | + file_name=file.filename, |
| 170 | + content_type=file.content_type or "application/octet-stream", |
| 171 | + size=file_size, |
| 172 | + url=f"/attachments/{file_id}", |
| 173 | + message="File uploaded successfully" |
| 174 | + ) |
| 175 | + |
| 176 | + except Exception as e: |
| 177 | + logger.error(f"Error processing attachment upload: {e}", exc_info=True) |
| 178 | + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") |
| 179 | + |
| 180 | + |
| 181 | +@router.get("/{file_id}") |
| 182 | +async def get_attachment(file_id: str): |
| 183 | + """ |
| 184 | + Retrieve an uploaded attachment. |
| 185 | + """ |
| 186 | + file_path = ATTACHMENTS_DIR / file_id |
| 187 | + metadata = _get_metadata(file_id) |
| 188 | + |
| 189 | + if not file_path.exists(): |
| 190 | + raise HTTPException(status_code=404, detail="Attachment not found") |
| 191 | + |
| 192 | + filename = metadata.get("original_filename", "attachment") if metadata else "attachment" |
| 193 | + content_type = metadata.get("content_type", "application/octet-stream") if metadata else None |
| 194 | + |
| 195 | + return FileResponse( |
| 196 | + path=file_path, |
| 197 | + filename=filename, |
| 198 | + media_type=content_type |
| 199 | + ) |
| 200 | + |
| 201 | + |
| 202 | +@router.delete("/{file_id}") |
| 203 | +async def delete_attachment(file_id: str): |
| 204 | + """ |
| 205 | + Delete an uploaded attachment. |
| 206 | + """ |
| 207 | + file_path = ATTACHMENTS_DIR / file_id |
| 208 | + meta_path = ATTACHMENTS_DIR / f"{file_id}.json" |
| 209 | + |
| 210 | + if not file_path.exists(): |
| 211 | + raise HTTPException(status_code=404, detail="Attachment not found") |
| 212 | + |
| 213 | + try: |
| 214 | + file_path.unlink(missing_ok=True) |
| 215 | + meta_path.unlink(missing_ok=True) |
| 216 | + return {"success": True, "message": "Attachment deleted"} |
| 217 | + except Exception as e: |
| 218 | + logger.error(f"Failed to delete attachment {file_id}: {e}") |
| 219 | + raise HTTPException(status_code=500, detail="Failed to delete attachment") |
0 commit comments