Skip to content

Commit 70470ce

Browse files
committed
feat: Implement core API services, attachment handling, and advanced voice AI features for form processing.
1 parent 44ea984 commit 70470ce

5 files changed

Lines changed: 447 additions & 2 deletions

File tree

form-flow-backend/main.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
from utils.rate_limit import limiter, rate_limit_exceeded_handler
4444

4545
# Import Routers
46-
from routers import auth, forms, speech, conversation, advanced_voice, analytics, websocket, local_llm, pdf, suggestions, docx, profile, snippets, plugins
46+
from routers import auth, forms, speech, conversation, advanced_voice, analytics, websocket, local_llm, pdf, suggestions, docx, profile, snippets, plugins, attachments
4747

4848
# Initialize logging
4949
setup_logging()
@@ -193,6 +193,7 @@ async def formflow_exception_handler(request: Request, exc: FormFlowError):
193193
app.include_router(profile.router)
194194
app.include_router(snippets.router)
195195
app.include_router(plugins.router)
196+
app.include_router(attachments.router)
196197

197198

198199
# =============================================================================
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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

Comments
 (0)