fix: FF calendar upload endpoint + path resolution for server deployment

- Add POST /api/eco/ff-upload to receive CSV from browser (saves to /tmp)
- _find_csv() checks /tmp, /app, project root — works local + Docker
- _run_ff_import now takes explicit csv_path parameter
- Frontend: Upload CSV button (file input) → uploads to server first,
  then Import into DB — with upload progress messages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 16:36:31 +02:00
parent 4053e0669d
commit b187107468
2 changed files with 81 additions and 14 deletions

View File

@@ -7,7 +7,7 @@ import logging
import os import os
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, File, HTTPException, Query, UploadFile
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/eco", tags=["Economic Calendar"]) router = APIRouter(prefix="/api/eco", tags=["Economic Calendar"])
@@ -296,17 +296,27 @@ def upcoming_events() -> List[Dict[str, Any]]:
_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None} _ff_import_status: Dict[str, Any] = {"running": False, "last_result": None}
_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None} _ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None}
_FF_CSV_PATH = os.path.join( # CSV may sit at project root (local dev) or in /tmp (uploaded via browser on server)
os.path.dirname(__file__), "..", "..", "forex_factory_cache.csv" _FF_CSV_CANDIDATES = [
) "/tmp/forex_factory_cache.csv",
os.path.join(os.path.dirname(__file__), "..", "forex_factory_cache.csv"),
os.path.join(os.path.dirname(__file__), "..", "..", "forex_factory_cache.csv"),
]
def _run_ff_import(): def _find_csv() -> str | None:
for p in _FF_CSV_CANDIDATES:
full = os.path.abspath(p)
if os.path.exists(full):
return full
return None
def _run_ff_import(csv_path: str):
global _ff_import_status global _ff_import_status
_ff_import_status["running"] = True _ff_import_status["running"] = True
try: try:
from services.ff_calendar import import_csv from services.ff_calendar import import_csv
csv_path = os.path.abspath(_FF_CSV_PATH)
result = import_csv(csv_path) result = import_csv(csv_path)
_ff_import_status["last_result"] = result _ff_import_status["last_result"] = result
except Exception as e: except Exception as e:
@@ -330,15 +340,30 @@ def _run_ff_sync():
_ff_sync_status["running"] = False _ff_sync_status["running"] = False
@router.post("/ff-upload")
async def ff_upload(file: UploadFile = File(...)) -> Dict[str, Any]:
"""Upload forex_factory_cache.csv directly from the browser (saves to /tmp)."""
dest = "/tmp/forex_factory_cache.csv"
try:
content = await file.read()
with open(dest, "wb") as f:
f.write(content)
size_mb = round(len(content) / 1_048_576, 1)
print(f"[FF upload] Saved {size_mb} MB to {dest}", flush=True)
return {"status": "uploaded", "path": dest, "size_mb": size_mb}
except Exception as e:
raise HTTPException(500, f"Upload failed: {e}")
@router.post("/ff-import") @router.post("/ff-import")
def ff_import(background_tasks: BackgroundTasks) -> Dict[str, Any]: def ff_import(background_tasks: BackgroundTasks) -> Dict[str, Any]:
"""Import forex_factory_cache.csv into ff_calendar table (background).""" """Import forex_factory_cache.csv into ff_calendar table (background)."""
if _ff_import_status["running"]: if _ff_import_status["running"]:
raise HTTPException(409, "Import already running") raise HTTPException(409, "Import already running")
csv_path = os.path.abspath(_FF_CSV_PATH) csv_path = _find_csv()
if not os.path.exists(csv_path): if not csv_path:
raise HTTPException(404, f"CSV not found: {csv_path}") raise HTTPException(404, "CSV not found. Upload it first via POST /api/eco/ff-upload")
background_tasks.add_task(_run_ff_import) background_tasks.add_task(_run_ff_import, csv_path)
return {"status": "started", "csv": csv_path} return {"status": "started", "csv": csv_path}

View File

@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react' import { useState, useEffect, useCallback, useRef, ChangeEvent } from 'react'
import clsx from 'clsx' import clsx from 'clsx'
import { RefreshCw, Download, ChevronDown, AlertTriangle, Check } from 'lucide-react' import { RefreshCw, Download, ChevronDown, AlertTriangle, Check, Upload } from 'lucide-react'
const API = '' const API = ''
@@ -132,10 +132,37 @@ function SurpriseChip({ ev }: { ev: FFEvent }) {
function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) { function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) {
const [status, setStatus] = useState<ImportStatus>({ running: false, last_result: null }) const [status, setStatus] = useState<ImportStatus>({ running: false, last_result: null })
const [syncResult, setSyncResult] = useState<string | null>(null) const [syncResult, setSyncResult] = useState<string | null>(null)
const [uploadMsg, setUploadMsg] = useState<string | null>(null)
const [uploading, setUploading] = useState(false)
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) const pollRef = useRef<ReturnType<typeof setInterval> | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const handleUpload = async (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setUploading(true)
setUploadMsg(`Uploading ${(file.size / 1_048_576).toFixed(1)} MB…`)
const form = new FormData()
form.append('file', file)
try {
const r = await fetch(`${API}/api/eco/ff-upload`, { method: 'POST', body: form }).then(x => x.json())
if (r.error) { setUploadMsg(`Upload error: ${r.error}`); return }
setUploadMsg(`✓ Uploaded ${r.size_mb} MB — click Import to load into DB`)
} catch (err) {
setUploadMsg(`Upload failed: ${err}`)
} finally {
setUploading(false)
if (fileRef.current) fileRef.current.value = ''
}
}
const startImport = async () => { const startImport = async () => {
await fetch(`${API}/api/eco/ff-import`, { method: 'POST' }) const r = await fetch(`${API}/api/eco/ff-import`, { method: 'POST' })
if (!r.ok) {
const err = await r.json().catch(() => ({}))
setUploadMsg(`Import error: ${err.detail ?? r.status}`)
return
}
pollRef.current = setInterval(async () => { pollRef.current = setInterval(async () => {
const r = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()) const r = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json())
setStatus(r) setStatus(r)
@@ -180,13 +207,23 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
</div> </div>
</div> </div>
{/* Upload CSV (for server deployment) */}
<label className={clsx(
'px-3 py-1.5 rounded bg-slate-600 hover:bg-slate-500 flex items-center gap-1.5 text-white cursor-pointer',
uploading && 'opacity-50 cursor-wait'
)}>
<Upload size={13} />
{uploading ? 'Uploading…' : 'Upload CSV'}
<input ref={fileRef} type="file" accept=".csv" className="hidden" onChange={handleUpload} disabled={uploading} />
</label>
<button <button
onClick={startImport} onClick={startImport}
disabled={status.running} disabled={status.running}
className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5 text-white" className="px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-wait flex items-center gap-1.5 text-white"
> >
<Download size={13} /> <Download size={13} />
{status.running ? 'Importing…' : 'Import CSV (2007-2025)'} {status.running ? 'Importing…' : 'Import into DB'}
</button> </button>
<button <button
@@ -197,6 +234,11 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
Sync Live (this week) Sync Live (this week)
</button> </button>
{uploadMsg && (
<span className={clsx('text-xs', uploadMsg.startsWith('✓') ? 'text-emerald-400' : uploadMsg.startsWith('Upload error') || uploadMsg.startsWith('Import error') ? 'text-red-400' : 'text-yellow-400')}>
{uploadMsg}
</span>
)}
{status.running && ( {status.running && (
<span className="text-yellow-400 text-xs animate-pulse">Importing CSV this may take 30-60 s</span> <span className="text-yellow-400 text-xs animate-pulse">Importing CSV this may take 30-60 s</span>
)} )}