add data source
This commit is contained in:
@@ -23,6 +23,7 @@ from app.schemas.chart import ChartGenerationResponse
|
|||||||
from app.agent.chart import generate_chart
|
from app.agent.chart import generate_chart
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models.datasource import DataSource
|
from app.models.datasource import DataSource
|
||||||
|
from app.core.files import resolve_upload_file_path
|
||||||
|
|
||||||
SCHEMA_CACHE_TTL_SECONDS = 300
|
SCHEMA_CACHE_TTL_SECONDS = 300
|
||||||
CONNECTION_CACHE_TTL_SECONDS = 30
|
CONNECTION_CACHE_TTL_SECONDS = 30
|
||||||
@@ -100,15 +101,10 @@ The final answer must be a ANSI SQL query in JSON format:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def _resolve_upload_file_path(file_url: Optional[str]) -> Path:
|
def _resolve_upload_file_path(file_url: Optional[str]) -> Path:
|
||||||
if not file_url or not file_url.startswith("local://"):
|
try:
|
||||||
raise ValueError("Invalid uploaded file URL")
|
return resolve_upload_file_path(file_url)
|
||||||
raw_name = file_url.replace("local://", "", 1)
|
except ValueError as e:
|
||||||
safe_name = os.path.basename(raw_name)
|
raise ValueError(f"Invalid uploaded file URL: {e}")
|
||||||
upload_dir = Path(__file__).resolve().parents[2] / "data" / "uploads"
|
|
||||||
file_path = upload_dir / safe_name
|
|
||||||
if not file_path.exists():
|
|
||||||
raise ValueError(f"Uploaded file not found: {safe_name}")
|
|
||||||
return file_path
|
|
||||||
|
|
||||||
def _load_upload_dataframe_from_path(file_path: Path) -> pd.DataFrame:
|
def _load_upload_dataframe_from_path(file_path: Path) -> pd.DataFrame:
|
||||||
suffix = file_path.suffix.lower()
|
suffix = file_path.suffix.lower()
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ upload_dir.mkdir(parents=True, exist_ok=True)
|
|||||||
|
|
||||||
@router.post("/upload/file")
|
@router.post("/upload/file")
|
||||||
async def upload_file(file: UploadFile = File(...)):
|
async def upload_file(file: UploadFile = File(...)):
|
||||||
allowed_extensions = ('.csv', '.xls', '.xlsx')
|
allowed_extensions = ('.csv', '.xls', '.xlsx', '.parquet', '.db', '.sqlite', '.sqlite3')
|
||||||
if not file.filename.lower().endswith(allowed_extensions):
|
filename_lower = file.filename.lower()
|
||||||
raise HTTPException(status_code=400, detail="Invalid file type. Only CSV and Excel files allowed.")
|
if not filename_lower.endswith(allowed_extensions):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid file type. Allowed: CSV, Excel, Parquet, SQLite.")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
@@ -29,11 +30,24 @@ async def upload_file(file: UploadFile = File(...)):
|
|||||||
file_obj.seek(0)
|
file_obj.seek(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if file.filename.lower().endswith('.csv'):
|
if filename_lower.endswith('.csv'):
|
||||||
df = pd.read_csv(file_obj)
|
df = pd.read_csv(file_obj)
|
||||||
else:
|
elif filename_lower.endswith(('.xls', '.xlsx')):
|
||||||
df = pd.read_excel(file_obj)
|
df = pd.read_excel(file_obj)
|
||||||
|
elif filename_lower.endswith('.parquet'):
|
||||||
|
df = pd.read_parquet(file_obj)
|
||||||
|
elif filename_lower.endswith(('.db', '.sqlite', '.sqlite3')):
|
||||||
|
# For SQLite, we don't load into DF immediately for analysis here
|
||||||
|
# Just return success
|
||||||
|
return {
|
||||||
|
"filename": unique_filename,
|
||||||
|
"url": file_url,
|
||||||
|
"rows": 0,
|
||||||
|
"columns": [],
|
||||||
|
"summary": "SQLite database uploaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
# For DF supported types
|
||||||
duckdb_conn = duckdb.connect(database=':memory:')
|
duckdb_conn = duckdb.connect(database=':memory:')
|
||||||
duckdb_conn.register('uploaded_file', df)
|
duckdb_conn.register('uploaded_file', df)
|
||||||
summary = duckdb_conn.execute("DESCRIBE uploaded_file").fetchall()
|
summary = duckdb_conn.execute("DESCRIBE uploaded_file").fetchall()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from app.connectors.postgres import PostgresConnector
|
|||||||
from app.connectors.clickhouse import ClickHouseConnector
|
from app.connectors.clickhouse import ClickHouseConnector
|
||||||
from app.connectors.parquet import ParquetConnector
|
from app.connectors.parquet import ParquetConnector
|
||||||
from app.models.datasource import DataSource
|
from app.models.datasource import DataSource
|
||||||
|
from app.core.files import resolve_upload_file_path
|
||||||
|
|
||||||
@functools.lru_cache(maxsize=32)
|
@functools.lru_cache(maxsize=32)
|
||||||
def _get_cached_connector(ds_type: str, config_json: str):
|
def _get_cached_connector(ds_type: str, config_json: str):
|
||||||
@@ -20,7 +21,8 @@ def _get_cached_connector(ds_type: str, config_json: str):
|
|||||||
# SQLite uses connection string usually file path
|
# SQLite uses connection string usually file path
|
||||||
db_url = config.get("connection_string")
|
db_url = config.get("connection_string")
|
||||||
if not db_url and config.get("file_path"):
|
if not db_url and config.get("file_path"):
|
||||||
db_url = f"sqlite:///{config.get('file_path')}"
|
file_path = str(resolve_upload_file_path(config.get("file_path")))
|
||||||
|
db_url = f"sqlite:///{file_path}"
|
||||||
return PostgresConnector(db_url=db_url)
|
return PostgresConnector(db_url=db_url)
|
||||||
|
|
||||||
elif ds_type == "clickhouse":
|
elif ds_type == "clickhouse":
|
||||||
@@ -33,7 +35,8 @@ def _get_cached_connector(ds_type: str, config_json: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif ds_type == "parquet":
|
elif ds_type == "parquet":
|
||||||
return ParquetConnector(file_path=config.get("file_path"))
|
file_path = str(resolve_upload_file_path(config.get("file_path")))
|
||||||
|
return ParquetConnector(file_path=file_path)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unsupported data source type: {ds_type}")
|
raise ValueError(f"Unsupported data source type: {ds_type}")
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
def resolve_upload_file_path(file_url: Optional[str]) -> Path:
|
||||||
|
if not file_url:
|
||||||
|
raise ValueError("File URL is empty")
|
||||||
|
|
||||||
|
if file_url.startswith("local://"):
|
||||||
|
raw_name = file_url.replace("local://", "", 1)
|
||||||
|
safe_name = os.path.basename(raw_name)
|
||||||
|
# Assuming we are in backend/app/core, go up to backend/data/uploads
|
||||||
|
upload_dir = Path(__file__).resolve().parents[2] / "data" / "uploads"
|
||||||
|
file_path = upload_dir / safe_name
|
||||||
|
return file_path
|
||||||
|
|
||||||
|
# If it's already an absolute path (or relative path not starting with local://)
|
||||||
|
return Path(file_url)
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from "react";
|
import { useState, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Loader2, Check, AlertTriangle } from "lucide-react";
|
import { Loader2, Check, AlertTriangle, Upload } from "lucide-react";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
|
||||||
export interface DataSourceConfig {
|
export interface DataSourceConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -24,11 +25,43 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
const [isTesting, setIsTesting] = useState(false);
|
const [isTesting, setIsTesting] = useState(false);
|
||||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const handleConfigChange = (key: string, value: any) => {
|
const handleConfigChange = (key: string, value: any) => {
|
||||||
setConfig(prev => ({ ...prev, [key]: value }));
|
setConfig(prev => ({ ...prev, [key]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFileSelect = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
const res = await api.post("/api/v1/upload/file", formData);
|
||||||
|
if (res && (res as any).url) {
|
||||||
|
handleConfigChange("file_path", (res as any).url);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload failed", error);
|
||||||
|
alert("上传失败");
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
// Clear input value so same file can be selected again
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTest = async () => {
|
const handleTest = async () => {
|
||||||
setIsTesting(true);
|
setIsTesting(true);
|
||||||
setTestResult(null);
|
setTestResult(null);
|
||||||
@@ -175,11 +208,24 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">File Path (Server Side)</label>
|
<label className="text-sm font-medium">File Path (Server Side)</label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
value={config.file_path || ""}
|
<Input
|
||||||
onChange={e => handleConfigChange("file_path", e.target.value)}
|
value={config.file_path || ""}
|
||||||
placeholder="/path/to/database.db"
|
onChange={e => handleConfigChange("file_path", e.target.value)}
|
||||||
/>
|
placeholder="/path/to/database.db"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleFileSelect} disabled={isUploading}>
|
||||||
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
key="sqlite-input"
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept=".db,.sqlite,.sqlite3"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -188,11 +234,24 @@ export function DataSourceForm({ initialData, onSubmit, onTest, onCancel }: Data
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium">File Path (Server Side)</label>
|
<label className="text-sm font-medium">File Path (Server Side)</label>
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
value={config.file_path || ""}
|
<Input
|
||||||
onChange={e => handleConfigChange("file_path", e.target.value)}
|
value={config.file_path || ""}
|
||||||
placeholder="/path/to/data.parquet"
|
onChange={e => handleConfigChange("file_path", e.target.value)}
|
||||||
/>
|
placeholder="/path/to/data.parquet"
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" onClick={handleFileSelect} disabled={isUploading}>
|
||||||
|
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
key="parquet-input"
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
className="hidden"
|
||||||
|
accept=".parquet"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user