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:
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user