-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.py
More file actions
188 lines (152 loc) · 6.14 KB
/
Copy pathmain.py
File metadata and controls
188 lines (152 loc) · 6.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
import uuid
import shutil
from pathlib import Path
from typing import List
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
# Import shared components
from worker import celery_app
from config import (
MCP_ENABLED,
MAX_UPLOAD_FILES,
MAX_UPLOAD_FILE_BYTES,
MAX_UPLOAD_TOTAL_BYTES,
OUTPUTS_DIR,
TEMP_UPLOADS_DIR,
UPLOAD_CHUNK_BYTES,
)
from schemas import JobSettings, JobResponse
# Import MCP Server components
from fastmcp import FastMCP
from mcp_tools import get_tools
app = FastAPI()
STATIC_DIR = Path(__file__).parent / "frontend" / "dist"
mcp_server = FastMCP(tools=get_tools()) if MCP_ENABLED else None
# Ensure base directories exist
TEMP_UPLOADS_DIR.mkdir(exist_ok=True)
OUTPUTS_DIR.mkdir(exist_ok=True)
# --- Helper Functions ---
def _parse_job_settings(settings: str) -> JobSettings:
"""Parse job settings JSON across both Pydantic v1 and v2."""
parser = getattr(JobSettings, "model_validate_json", None)
if parser is not None:
return parser(settings)
return JobSettings.parse_raw(settings)
def _dump_job_settings(job_settings: JobSettings) -> dict:
"""Serialize settings for Celery across both Pydantic v1 and v2."""
dumper = getattr(job_settings, "model_dump", None)
if dumper is not None:
return dumper()
return job_settings.dict()
async def _save_uploads(files: List[UploadFile], job_dir: Path) -> None:
"""Write uploads with bounded file and aggregate sizes to protect disk and workers."""
if len(files) > MAX_UPLOAD_FILES:
raise HTTPException(status_code=413, detail=f"Too many files; maximum is {MAX_UPLOAD_FILES}.")
total_bytes = 0
for file in files:
if not file.filename:
await file.close()
continue
clean_filename = Path(file.filename).name
if not clean_filename or clean_filename in {".", ".."}:
await file.close()
continue
file_path = job_dir / clean_filename
file_bytes = 0
try:
with open(file_path, "wb") as buffer:
while chunk := await file.read(UPLOAD_CHUNK_BYTES):
file_bytes += len(chunk)
total_bytes += len(chunk)
if file_bytes > MAX_UPLOAD_FILE_BYTES:
raise HTTPException(status_code=413, detail="An uploaded file is too large.")
if total_bytes > MAX_UPLOAD_TOTAL_BYTES:
raise HTTPException(status_code=413, detail="The upload is too large.")
buffer.write(chunk)
except Exception:
file_path.unlink(missing_ok=True)
raise
finally:
await file.close()
def get_job_status(job_id: str):
"""Helper to get the status of a Celery task."""
task_result = celery_app.AsyncResult(job_id)
response = {
"job_id": job_id,
"status": task_result.status.lower(),
"message": "",
"result": None,
"error": None
}
if task_result.successful():
response["message"] = "Job completed successfully."
response["result"] = task_result.result
elif task_result.failed():
response["message"] = "Job failed."
response["error"] = str(task_result.info)
elif task_result.status == 'PENDING':
response["message"] = "Job is queued and waiting to be processed."
elif task_result.status == 'STARTED':
response["message"] = "Job is currently being processed."
else:
response["message"] = f"Job is in an unknown state: {task_result.status}"
return response
# --- API Endpoints ---
@app.post("/api/jobs", response_model=JobResponse)
async def create_packaging_job(
files: List[UploadFile] = File(...),
settings: str = Form(...), # Settings will be a JSON string
):
"""Accepts photo uploads and job settings to start a packaging job."""
try:
job_settings = _parse_job_settings(settings)
except Exception as e:
raise HTTPException(status_code=400, detail=f"Invalid settings format: {e}")
job_id = str(uuid.uuid4())
job_dir = TEMP_UPLOADS_DIR / job_id
job_dir.mkdir(parents=True, exist_ok=True)
try:
await _save_uploads(files, job_dir)
except Exception:
shutil.rmtree(job_dir, ignore_errors=True)
raise
# Launch background task with Celery
celery_app.send_task(
"worker.run_packaging_job",
args=[job_id, str(job_dir), _dump_job_settings(job_settings)],
task_id=job_id
)
return JobResponse(
job_id=job_id,
status="queued",
message=f"Job '{job_id}' has been queued. {len(files)} files received."
)
@app.get("/api/jobs/{job_id}/status")
async def get_job_status_api(job_id: str):
"""Endpoint to poll for the status of a job."""
return JSONResponse(content=get_job_status(job_id))
@app.get("/api/jobs/{job_id}/download/{zip_filename}")
async def download_zip_package(job_id: str, zip_filename: str):
"""Allows downloading of a packaged ZIP file."""
# Strict validation of job_id structure (UUID format)
try:
import uuid
val = uuid.UUID(job_id, version=4)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job identifier formatting.")
# Strict zip_filename sanitization
clean_filename = Path(zip_filename).name
if not clean_filename or not clean_filename.endswith('.zip') or clean_filename in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid filename format or extension.")
file_path = OUTPUTS_DIR / job_id / clean_filename
if not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(file_path, media_type='application/zip', filename=zip_filename)
def _mount_runtime_apps() -> None:
"""Mount runtime sub-apps after API routes so they do not shadow /api."""
if mcp_server is not None:
app.mount("/mcp", mcp_server)
if STATIC_DIR.is_dir():
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="frontend")
_mount_runtime_apps()