DataVision CI/CD Bot
release: clean production build for HuggingFace Space
ee7d7b9
Raw
History Blame Contribute Delete
57.9 kB
"""
Real Files Management - NO FAKE DATA
Everything based on actual uploaded files
Enterprise-grade with auto currency detection
With cache invalidation for data consistency
SECURED: Uses JWT authentication for user isolation
"""
from fastapi import APIRouter, File, UploadFile, HTTPException, Query, Header, Depends, Request
from fastapi.responses import JSONResponse, FileResponse, StreamingResponse
from typing import List, Optional, Dict
from pathlib import Path
import shutil
import traceback
from datetime import datetime
import re
import os
import json
from ingestion.pipeline import IngestionPipeline
from config.settings import Settings
from utils.paths import get_user_paths, STORAGE_BASE
from database.db import get_db
from sqlalchemy.ext.asyncio import AsyncSession
from database.orm import UserFile
import uuid
from utils.currency import (
detect_and_save_user_currency,
save_currency_metadata,
load_currency_metadata,
CURRENCY_CONFIG
)
# Import auth helper for JWT decode
try:
from database.auth import decode_jwt, get_user_id_from_headers
except ImportError:
decode_jwt = None
def get_user_id_from_headers(*args, **kwargs): return None # Return None to preserve URL user_id
router = APIRouter()
# ============================================
# SECURITY: File Upload Validation
# ============================================
# Allowed file extensions (whitelist approach)
ALLOWED_EXTENSIONS = {'.csv', '.xlsx', '.xls', '.json', '.pdf', '.docx', '.doc', '.txt', '.png', '.jpg', '.jpeg'}
# Large tabular/BI extracts are common in production. The reverse proxy should
# use the same value (2 GiB) when one is configured.
MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024 # 2 GiB max
MAX_FILENAME_LENGTH = 255
def sanitize_filename(filename: str) -> str:
"""
Sanitize filename to prevent path traversal attacks.
- Remove path separators
- Remove null bytes
- Limit length
- Only allow safe characters
"""
if not filename:
raise HTTPException(status_code=400, detail="Filename cannot be empty")
# Remove path separators and parent directory references
filename = os.path.basename(filename)
filename = filename.replace('..', '')
filename = filename.replace('\x00', '') # Remove null bytes
# Remove any remaining path separators
filename = re.sub(r'[/\\]', '', filename)
# Limit filename length
if len(filename) > MAX_FILENAME_LENGTH:
name, ext = os.path.splitext(filename)
filename = name[:MAX_FILENAME_LENGTH - len(ext)] + ext
# Ensure filename is not empty after sanitization
if not filename or filename in ('.', '..'):
raise HTTPException(status_code=400, detail="Invalid filename")
return filename
def validate_file(file: UploadFile) -> None:
"""Validate uploaded file for security"""
# Sanitize and validate filename
sanitized_name = sanitize_filename(file.filename)
# Check file extension (whitelist)
ext = os.path.splitext(sanitized_name)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=f"File type '{ext}' not allowed. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
)
# Update filename with sanitized version
file.filename = sanitized_name
def validate_user_id(user_id: str) -> str:
"""Validate user_id to prevent path traversal"""
if not user_id or not re.match(r'^[a-zA-Z0-9_-]+$', user_id):
raise HTTPException(status_code=400, detail="Invalid user ID format")
if '..' in user_id or '/' in user_id or '\\' in user_id:
raise HTTPException(status_code=400, detail="Invalid user ID")
return user_id
# ============================================
# UPLOAD CANCELLATION STATE MANAGEMENT
# ============================================
# In-memory store to track cancellation requests per user
# This allows the cancel button to actually stop ongoing processing
_upload_cancellation_flags: Dict[str, bool] = {}
def request_cancellation(user_id: str) -> None:
"""Set cancellation flag for a user's upload"""
_upload_cancellation_flags[user_id] = True
print(f"🛑 Cancellation requested for user: {user_id}")
def clear_cancellation(user_id: str) -> None:
"""Clear cancellation flag after upload completes or is cancelled"""
_upload_cancellation_flags.pop(user_id, None)
def is_upload_cancelled(user_id: str) -> bool:
"""Check if user has requested upload cancellation"""
return _upload_cancellation_flags.get(user_id, False)
# Cancel upload endpoint - Frontend calls this when user clicks Cancel
@router.post("/cancel-upload/{user_id}")
async def cancel_upload(user_id: str):
"""Cancel an ongoing upload for a user"""
request_cancellation(user_id)
return {"success": True, "message": "Cancellation requested"}
def invalidate_user_cache(user_id: str):
"""Invalidate query cache when data changes"""
try:
from core.cache import get_cache
cache = get_cache()
cache.invalidate(user_id=user_id)
print(f"🗑️ Query cache invalidated for user: {user_id}")
except Exception as e:
print(f"⚠️ Cache invalidation error: {e}")
@router.post("/upload/{user_id}")
async def upload_files(
request: Request, # Moved to first position for proper FastAPI injection
user_id: str,
files: List[UploadFile] = File(...),
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization"),
db: AsyncSession = Depends(get_db)
):
"""Upload and process files with REAL ingestion pipeline
SECURED: Validates user identity from JWT token
CANCELLABLE: Stops processing if client disconnects or cancel is requested
"""
# SECURITY: Validate user_id to prevent path traversal
user_id = validate_user_id(user_id)
# Get the actual user_id first for cancellation checks
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
actual_user_id = authenticated_user if authenticated_user else user_id
# SECURITY: Validate authenticated user_id as well
if actual_user_id:
actual_user_id = validate_user_id(actual_user_id)
# Clear any previous cancellation flag at the start
clear_cancellation(actual_user_id)
# Helper to check if client cancelled (checks BOTH disconnection AND explicit cancel flag)
async def is_cancelled() -> bool:
# Check explicit cancellation flag first (set by cancel-upload endpoint)
if is_upload_cancelled(actual_user_id):
return True
# Also check if HTTP connection was closed
try:
return await request.is_disconnected()
except:
return False
# Helper to clean up files on cancellation
def cleanup_uploaded_files(file_list: list, paths_obj):
for file_info in file_list:
try:
file_path = paths_obj["files"] / file_info["name"]
if file_path.exists():
file_path.unlink()
print(f"🗑️ Cleaned up cancelled file: {file_info['name']}")
except Exception as e:
print(f"⚠️ Cleanup error: {e}")
try:
# For backwards compatibility, use URL user_id but log warning if mismatch
if authenticated_user != user_id:
print(f"⚠️ WARNING: Auth user {authenticated_user} ≠ URL user {user_id}")
# Use authenticated user for security
user_id = authenticated_user
paths = get_user_paths(user_id)
uploaded_files = []
# Invalidate cache since data is changing
invalidate_user_cache(user_id)
# Check for cancellation before file save
if await is_cancelled():
print("🛑 Upload cancelled by client before file save")
clear_cancellation(user_id)
return {"success": False, "cancelled": True, "message": "Upload cancelled by user"}
# Save uploaded files
for file in files:
# SECURITY: Validate each uploaded file
validate_file(file)
declared_size = getattr(file, "size", None)
if declared_size is not None and declared_size > MAX_FILE_SIZE:
raise HTTPException(status_code=413, detail="File exceeds the 2 GB upload limit.")
# Check cancellation for each file
if await is_cancelled():
print(f"🛑 Upload cancelled by client during file save ({file.filename})")
cleanup_uploaded_files(uploaded_files, paths)
clear_cancellation(user_id)
return {"success": False, "cancelled": True, "message": "Upload cancelled by user"}
# 🔍 Deduplication / Schema Conflict Logic
import pandas as pd
# Check if there are existing files
existing_csvs = list(paths["files"].glob("*.csv"))
existing_csvs = [f for f in existing_csvs if not f.name.startswith("cleaned_") and not f.name.startswith("LIVE_")]
if len(existing_csvs) > 0 and file.filename.endswith(".csv"):
try:
# Read header of the incoming file without saving it yet
file.file.seek(0)
new_df = pd.read_csv(file.file, nrows=0)
new_cols = set(new_df.columns)
# Read header of the first existing file
existing_df = pd.read_csv(existing_csvs[0], nrows=0)
existing_cols = set(existing_df.columns)
if new_cols != existing_cols:
# Schemas don't match! This is a different dataset.
raise HTTPException(
status_code=409,
detail="⚠️ Different dataset detected! Please delete your previous data files first to avoid AI context conflicts."
)
else:
print(f"✅ Schema matched existing data. Allowing upload of {file.filename}.")
except HTTPException:
raise
except Exception as e:
print(f"⚠️ Could not verify schema: {e}")
file.file.seek(0)
file_path = paths["files"] / file.filename
file.file.seek(0)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
uploaded_files.append({
"id": file.filename,
"name": file.filename,
"size": file_path.stat().st_size,
"type": file.content_type or "unknown",
"uploadedAt": datetime.now().isoformat(),
"status": "processing"
})
# DETECT CURRENCY from uploaded files
detected_currency = detect_and_save_user_currency(user_id, paths["files"], STORAGE_BASE)
# Check for cancellation before pipeline processing
if await is_cancelled():
print("🛑 Upload cancelled by client before pipeline processing")
cleanup_uploaded_files(uploaded_files, paths)
clear_cancellation(user_id)
return {"success": False, "cancelled": True, "message": "Upload cancelled by user"}
# Process with REAL pipeline - NO FAKE DATA
try:
# Set paths for this user
Settings.UPLOADS = paths["files"]
Settings.FAISS_DIR = paths["faiss"]
Settings.GRAPH_DIR = paths["graph"]
# Create pipeline
pipeline = IngestionPipeline()
# IMPORTANT: Process ALL files in the user's directory, not just newly uploaded ones
# This ensures a complete, unified dataset when adding new files
all_files_in_dir = []
for f in paths["files"].iterdir():
if f.is_file():
all_files_in_dir.append({
"id": f.name,
"name": f.name,
"size": f.stat().st_size,
"type": f.suffix[1:] if f.suffix else "unknown",
})
print(f"📁 Reprocessing ALL {len(all_files_in_dir)} files after new upload")
# Check for cancellation before heavy processing
if await is_cancelled():
print("🛑 Upload cancelled by client before pipeline.process()")
cleanup_uploaded_files(uploaded_files, paths)
clear_cancellation(user_id)
return {"success": False, "cancelled": True, "message": "Upload cancelled by user"}
# Process ALL files with REAL pipeline
result = pipeline.process(
all_files_in_dir,
user_id=user_id,
base_path=STORAGE_BASE
)
# Check for cancellation AFTER heavy processing - user may have cancelled during pipeline
if await is_cancelled():
print("🛑 Upload cancelled by client AFTER pipeline.process() - cleaning up")
cleanup_uploaded_files(uploaded_files, paths)
clear_cancellation(user_id)
return {"success": False, "cancelled": True, "message": "Upload cancelled by user"}
# Map pipeline results back to file info and SAVE TO DB
for file_info in uploaded_files:
file_info["status"] = "completed"
file_info["trained"] = True
file_info["currency"] = detected_currency
# Check if it exists in DB, otherwise insert
try:
from sqlalchemy import select
import uuid as _uuid
try:
uid = _uuid.UUID(user_id)
except ValueError:
uid = _uuid.uuid5(_uuid.NAMESPACE_OID, str(user_id))
existing = (await db.execute(
select(UserFile).filter(UserFile.user_id == uid, UserFile.filename == file_info["name"])
)).scalars().first()
if not existing:
import pandas as pd
row_count = 0
col_count = 0
file_path = paths["files"] / file_info["name"]
if file_path.suffix.lower() == '.csv':
try:
df = pd.read_csv(file_path)
row_count = len(df)
col_count = len(df.columns)
except: pass
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
try:
df = pd.read_excel(file_path)
row_count = len(df)
col_count = len(df.columns)
except: pass
new_file = UserFile(
user_id=uid,
filename=file_info["name"],
file_size_bytes=file_info["size"],
content_type=file_info["type"],
is_processed=True,
row_count=row_count,
column_count=col_count,
metadata_json={"currency": detected_currency}
)
db.add(new_file)
await db.commit()
except Exception as db_err:
print(f"⚠️ Failed to save file {file_info['name']} to database: {db_err}")
except Exception as e:
print(f"Processing error: {e}")
import traceback as tb
tb.print_exc()
for file_info in uploaded_files:
file_info["status"] = "failed"
file_info["error"] = str(e)
# Check for cancellation before schema analysis
if await is_cancelled():
print("🛑 Upload cancelled by client before schema analysis")
return {"success": True, "files": uploaded_files, "message": "Partial upload - cancelled before schema analysis", "cancelled": True}
# ============================================
# 🧠 AI SCHEMA INTELLIGENCE - Auto-analyze upload
# ============================================
print("=" * 60)
print("🧠 STARTING AI SCHEMA INTELLIGENCE ANALYSIS...")
print("=" * 60)
schema_result = None
try:
print("📦 Importing schema_intelligence module...")
from core.schema_intelligence import UniversalSchemaAnalyzer, SchemaStorage
print("✅ Import successful!")
# Read the first uploaded CSV/Excel for schema analysis
print(f"📁 Looking for files in: {paths['files']}")
print(f"📁 Files uploaded: {[f.filename for f in files]}")
for file in files:
# Check cancellation for each file schema analysis
if await is_cancelled():
print("🛑 Upload cancelled by client during schema analysis")
break
file_path = paths["files"] / file.filename
print(f"🔍 Checking file: {file_path}")
print(f"🔍 File suffix: {file_path.suffix.lower()}")
print(f"🔍 File exists: {file_path.exists()}")
if file_path.suffix.lower() in ['.csv', '.xlsx', '.xls']:
import pandas as pd
print(f"📊 Reading file: {file_path}")
if file_path.suffix.lower() == '.csv':
df = pd.read_csv(file_path)
else:
df = pd.read_excel(file_path)
print(f"📊 DataFrame loaded: {len(df)} rows, {len(df.columns)} columns")
print(f"📊 Columns: {list(df.columns)}")
print(f"🧠 AI Schema Analysis starting for: {file.filename}")
analyzer = UniversalSchemaAnalyzer()
schema = analyzer.analyze_dataframe(df, file.filename)
# Store schema in user's base directory
schema_dir = paths["base"] / "schemas"
schema_dir.mkdir(parents=True, exist_ok=True)
print(f"💾 Saving schema to: {schema_dir}")
storage = SchemaStorage(schema_dir)
storage.save_schema(file_path.stem, schema)
schema_result = {
"domain": schema.domain,
"domain_confidence": schema.domain_confidence,
"key_metrics": schema.key_metrics,
"dimensions": schema.dimensions,
"suggested_analyses": len(schema.suggested_analyses)
}
print(f"✅ Schema detected: {schema.domain} (confidence: {schema.domain_confidence:.0%})")
print(f"📊 Key metrics: {schema.key_metrics}")
print(f"📁 Dimensions: {schema.dimensions}")
print("=" * 60)
break
else:
print(f"⏭️ Skipping non-data file: {file.filename}")
except Exception as e:
print(f"❌ Schema analysis ERROR: {e}")
import traceback
traceback.print_exc()
# Clear cancellation flag on successful completion
clear_cancellation(user_id)
return {
"success": True,
"files": uploaded_files,
"message": f"{len(uploaded_files)} file(s) processed with REAL data",
"detectedCurrency": detected_currency,
"schemaIntelligence": schema_result
}
except Exception as e:
# Clear cancellation flag on error too
clear_cancellation(user_id)
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/schema/{user_id}")
async def get_schema_intelligence(
user_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""
🧠 AI Schema Intelligence Endpoint
Returns the AI-detected schema for user's uploaded data.
Includes domain detection, key metrics, dimensions, and suggested analyses.
"""
try:
# SECURITY: Validate user
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user != user_id:
user_id = authenticated_user
paths = get_user_paths(user_id)
# Try to load stored schema
from core.schema_intelligence import SchemaStorage, SchemaIntelligence
schema_dir = paths["base"] / "schemas"
if not schema_dir.exists():
return {
"hasSchema": False,
"message": "No schema found. Upload data files first."
}
# Get latest schema file
schema_files = list(schema_dir.glob("*_schema.json"))
if not schema_files:
return {
"hasSchema": False,
"message": "No schema found. Upload data files first."
}
# Load latest schema
latest_file = max(schema_files, key=lambda x: x.stat().st_mtime)
storage = SchemaStorage(schema_dir)
file_id = latest_file.stem.replace("_schema", "")
schema = storage.load_schema(file_id)
if schema:
return {
"hasSchema": True,
"schema": schema.to_dict(),
"summary": {
"domain": schema.domain,
"domainConfidence": f"{schema.domain_confidence:.0%}",
"keyMetrics": schema.key_metrics,
"dimensions": schema.dimensions,
"timeColumn": schema.time_column,
"suggestedAnalysesCount": len(schema.suggested_analyses),
"dataQuality": {
"completeness": f"{schema.data_quality.completeness:.0%}",
"rows": schema.data_quality.row_count,
"columns": schema.data_quality.column_count
}
}
}
else:
return {
"hasSchema": False,
"message": "Schema file exists but could not be loaded."
}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/schema/{user_id}/refresh")
async def refresh_schema_intelligence(
user_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""
Re-analyze uploaded files and refresh schema intelligence.
Use this when data files change.
"""
try:
# SECURITY: Validate user
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user != user_id:
user_id = authenticated_user
paths = get_user_paths(user_id)
# Find CSV/Excel files
data_files = []
if paths["files"].exists():
for f in paths["files"].iterdir():
if f.suffix.lower() in ['.csv', '.xlsx', '.xls']:
data_files.append(f)
if not data_files:
raise HTTPException(status_code=400, detail="No CSV/Excel files found. Upload data first.")
# Analyze the first/latest file
import pandas as pd
from core.schema_intelligence import UniversalSchemaAnalyzer, SchemaStorage
file_path = max(data_files, key=lambda x: x.stat().st_mtime)
if file_path.suffix.lower() == '.csv':
df = pd.read_csv(file_path)
else:
df = pd.read_excel(file_path)
print(f"🧠 Refreshing schema analysis for: {file_path.name}")
analyzer = UniversalSchemaAnalyzer()
schema = analyzer.analyze_dataframe(df, file_path.name)
# Store schema
schema_dir = paths["base"] / "schemas"
schema_dir.mkdir(parents=True, exist_ok=True)
storage = SchemaStorage(schema_dir)
storage.save_schema(file_path.stem, schema)
return {
"success": True,
"message": f"Schema refreshed for {file_path.name}",
"schema": {
"domain": schema.domain,
"domainConfidence": f"{schema.domain_confidence:.0%}",
"keyMetrics": schema.key_metrics,
"dimensions": schema.dimensions,
"suggestedAnalyses": len(schema.suggested_analyses)
}
}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/list/{user_id}")
async def list_files(
user_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""List all uploaded files - SECURED"""
try:
# SECURITY: Validate user (only override if auth returns valid user)
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user and authenticated_user != user_id:
user_id = authenticated_user
paths = get_user_paths(user_id)
files = []
# DEBUG: Print path resolution
print(f"📂 [FILE LIST] user_id={user_id}")
print(f"📂 [FILE LIST] files_path={paths['files']}")
print(f"📂 [FILE LIST] exists={paths['files'].exists()}")
if paths["files"].exists():
for file_path in paths["files"].iterdir():
print(f"📄 [FILE LIST] Found: {file_path}")
# HIDE Generated Cleaned files from the main list (User Request)
if file_path.name.startswith("cleaned_") or file_path.name.endswith(".telemetry.json"):
continue
if file_path.is_file():
files.append({
"id": file_path.name,
"name": file_path.name,
"size": file_path.stat().st_size,
"type": file_path.suffix[1:] if file_path.suffix else "unknown",
"uploadedAt": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat(),
"status": "completed"
})
# ✨ MAGIC: Append Live Connections as virtual files so ML/Reports can use them natively!
# Also discover any live_stream_*.csv files on disk that were created by push endpoints
live_file_ids = set(f["id"] for f in files) # Track already-listed files
try:
from database.db import AsyncSessionLocal
from database.orm import DataConnection
from sqlalchemy import select
import asyncio
async def get_live_connections():
# Only query if user_id is a valid UUID (not a guest string)
import uuid
try:
uuid_val = uuid.UUID(user_id)
except ValueError:
return []
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.user_id == uuid_val))
return result.scalars().all()
connections = await get_live_connections()
for conn in connections:
file_id = f"live_stream_{str(conn.id)[:12]}.csv"
if file_id in live_file_ids:
continue # Already listed from disk scan above
source_label = conn.source_type.upper()
table_name = (conn.connection_params or {}).get("target_table") or conn.database_name or "live_data"
# Check if CSV actually exists on disk
csv_path = paths["files"] / file_id
actual_size = csv_path.stat().st_size if csv_path.exists() else 0
files.append({
"id": file_id,
"name": f"📡 {table_name} ({source_label} Live)",
"size": actual_size,
"type": "csv",
"uploadedAt": conn.created_at.isoformat(),
"status": "streaming" if actual_size > 0 else "waiting",
"is_live": True
})
except Exception as live_e:
print(f"⚠️ Could not list live connections: {live_e}")
return {"files": files, "total": len(files)}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# IMPORTANT: This route MUST come before /{user_id}/{file_id} to properly match /all
@router.delete("/{user_id}/all")
async def delete_all_files(
user_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""Delete all files and indexes - SECURED"""
try:
# SECURITY: Validate user
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user != user_id:
user_id = authenticated_user
paths = get_user_paths(user_id)
# Delete files directory and recreate
if paths["files"].exists():
shutil.rmtree(paths["files"])
paths["files"].mkdir(parents=True, exist_ok=True)
# Delete FAISS directory and recreate
if paths["faiss"].exists():
shutil.rmtree(paths["faiss"])
paths["faiss"].mkdir(parents=True, exist_ok=True)
# Delete graph directory and recreate
if paths["graph"].exists():
shutil.rmtree(paths["graph"])
paths["graph"].mkdir(parents=True, exist_ok=True)
# Delete memory directory and recreate
if paths["memory"].exists():
shutil.rmtree(paths["memory"])
paths["memory"].mkdir(parents=True, exist_ok=True)
# Clear metadata
metadata_path = STORAGE_BASE / user_id / "metadata.json"
if metadata_path.exists():
metadata_path.unlink()
# Invalidate cache
invalidate_user_cache(user_id)
print(f"✅ All data deleted for user {user_id}")
return {
"success": True,
"message": f"All data deleted for user {user_id}",
"deleted": {
"files": True,
"faiss": True,
"graph": True,
"memory": True
}
}
except Exception as e:
print(f"❌ Error deleting files: {str(e)}")
print(traceback.format_exc())
raise HTTPException(status_code=500, detail=f"Failed to delete files: {str(e)}")
@router.delete("/{user_id}/{file_id}")
async def delete_file(
user_id: str,
file_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""Delete a file and retrain indexes - SECURED"""
try:
# SECURITY: Validate user
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user != user_id:
user_id = authenticated_user
# ✨ MAGIC: Intercept Live Connection Deletions
if file_id.startswith("LIVE_") and file_id.endswith(".csv"):
conn_id = file_id.replace("LIVE_", "").replace(".csv", "")
from database.db import AsyncSessionLocal
from database.orm import DataConnection
from sqlalchemy import select
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.id == conn_id))
conn = result.scalar_one_or_none()
if conn:
await db.delete(conn)
await db.commit()
invalidate_user_cache(user_id)
return {"success": True, "message": "Live connection deleted"}
paths = get_user_paths(user_id)
file_path = paths["files"] / file_id
# Invalidate cache since data is changing
invalidate_user_cache(user_id)
print(f"🗑️ Attempting to delete: {file_path}")
print(f"🗑️ File exists: {file_path.exists()}")
if not file_path.exists():
print(f"❌ File not found: {file_path}")
if file_id.startswith("live_stream_") and file_id.endswith(".csv"):
telemetry_path = paths["files"] / f"{Path(file_id).stem}.telemetry.json"
if telemetry_path.exists():
telemetry_path.unlink()
# The Space filesystem may already have expired; deletion is safe.
return {"success": True, "message": "Live stream file was already removed"}
raise HTTPException(status_code=404, detail=f"File not found: {file_id}")
# Delete the file FIRST
file_path.unlink()
print(f"✅ File deleted: {file_id}")
# ALSO DELETE ALL cleaned_*.csv files (AutoML generated files)
# These are temporary files from ML training and should be cleared on delete
cleaned_files = list(paths["files"].glob("cleaned_*.csv"))
for cf in cleaned_files:
try:
cf.unlink()
print(f"🗑️ Deleted AutoML cleaned file: {cf.name}")
except Exception as e:
print(f"⚠️ Could not delete {cf.name}: {e}")
# COMPLETELY clear old indexes for fresh retraining
# Clear FAISS index and metadata completely
if paths["faiss"].exists():
shutil.rmtree(paths["faiss"])
paths["faiss"].mkdir(parents=True, exist_ok=True)
print(f"🗑️ Cleared FAISS index and metadata")
# Clear Graph completely (user-specific)
if paths["graph"].exists():
shutil.rmtree(paths["graph"])
paths["graph"].mkdir(parents=True, exist_ok=True)
print(f"🗑️ Cleared User Graph index")
# Also clear global graph locations
global_graph_path = Settings.STORAGE / "graph" / f"{user_id}.gpickle"
if global_graph_path.exists():
global_graph_path.unlink()
print(f"🗑️ Cleared global graph file")
alt_graph_path = Settings.GRAPH_DIR / f"{user_id}.gpickle"
if alt_graph_path.exists():
alt_graph_path.unlink()
print(f"🗑️ Cleared alt graph file")
# Get remaining files for retraining (EXCLUDING cleaned_ files)
remaining_files = [
f for f in paths["files"].glob("*")
if f.is_file() and not f.name.startswith("cleaned_")
]
print(f"📁 Remaining files for retraining: {[f.name for f in remaining_files]}")
if remaining_files:
print(f"🔄 Starting retraining with {len(remaining_files)} remaining files...")
# Set paths for this user
Settings.UPLOADS = paths["files"]
Settings.FAISS_DIR = paths["faiss"]
Settings.GRAPH_DIR = paths["graph"]
# Create file info for pipeline
file_infos = []
for f in remaining_files:
file_infos.append({
"id": f.name,
"name": f.name,
"size": f.stat().st_size,
"type": f.suffix[1:] if f.suffix else "unknown",
})
# FRESH process remaining files with IngestionPipeline
try:
pipeline = IngestionPipeline()
result = pipeline.process(
file_infos,
user_id=user_id,
base_path=STORAGE_BASE
)
print(f"✅ Retraining complete: {result}")
except Exception as e:
print(f"⚠️ Retraining error: {e}")
traceback.print_exc()
# Re-detect currency after file deletion
detected_currency = detect_and_save_user_currency(user_id, paths["files"], STORAGE_BASE)
print(f"💰 Re-detected currency: {detected_currency}")
else:
print(f"📭 No remaining files - all indexes cleared")
# Clear metadata when no files remain
metadata_path = STORAGE_BASE / user_id / "metadata.json"
if metadata_path.exists():
metadata_path.unlink()
print(f"🗑️ Cleared user metadata (no files remaining)")
return {
"success": True,
"message": f"File {file_id} deleted and AI retrained from scratch",
"remaining_files": len(remaining_files),
"retrained": len(remaining_files) > 0,
"files_remaining": [f.name for f in remaining_files] if remaining_files else []
}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{user_id}/rebuild")
async def rebuild_indexes(user_id: str):
"""RETRAIN: Rebuild indexes from existing files (clears old data first) - FRESH training"""
try:
paths = get_user_paths(user_id)
# First, delete all cleaned_*.csv files (AutoML generated)
cleaned_files = list(paths["files"].glob("cleaned_*.csv"))
for cf in cleaned_files:
try:
cf.unlink()
print(f"🗑️ Deleted AutoML cleaned file: {cf.name}")
except Exception as e:
print(f"⚠️ Could not delete {cf.name}: {e}")
# Get only original files (not cleaned_ files)
files = [
f for f in paths["files"].glob("*")
if f.is_file() and not f.name.startswith("cleaned_")
]
if not files:
raise HTTPException(status_code=400, detail="No files to retrain. Upload files first.")
print(f"🔄 Starting FRESH rebuild for user {user_id} with {len(files)} files")
print(f"📁 Files to process: {[f.name for f in files]}")
# INVALIDATE CACHE - Clear all cached responses for this user
invalidate_user_cache(user_id)
# COMPLETELY clear old indexes
if paths["faiss"].exists():
shutil.rmtree(paths["faiss"])
paths["faiss"].mkdir(parents=True, exist_ok=True)
print(f"🗑️ Cleared FAISS index and metadata")
if paths["graph"].exists():
shutil.rmtree(paths["graph"])
paths["graph"].mkdir(parents=True, exist_ok=True)
print(f"🗑️ Cleared User Graph index")
# Also clear the global graph directory for this user
global_graph_path = Settings.STORAGE / "graph" / f"{user_id}.gpickle"
if global_graph_path.exists():
global_graph_path.unlink()
print(f"🗑️ Cleared global graph file: {global_graph_path}")
# Clear any other graph file locations
alt_graph_path = Settings.GRAPH_DIR / f"{user_id}.gpickle"
if alt_graph_path.exists():
alt_graph_path.unlink()
print(f"🗑️ Cleared alt graph file: {alt_graph_path}")
# Clear old metadata to force fresh currency detection
metadata_path = STORAGE_BASE / user_id / "metadata.json"
if metadata_path.exists():
metadata_path.unlink()
print(f"🗑️ Cleared old metadata for fresh currency detection")
# Set paths
Settings.UPLOADS = paths["files"]
Settings.FAISS_DIR = paths["faiss"]
Settings.GRAPH_DIR = paths["graph"]
# Create file info for pipeline
file_infos = []
for f in files:
file_infos.append({
"id": f.name,
"name": f.name,
"size": f.stat().st_size,
"type": f.suffix[1:] if f.suffix else "unknown",
})
# FRESH process ALL files with IngestionPipeline
print(f"🔄 Processing {len(file_infos)} files with IngestionPipeline...")
try:
pipeline = IngestionPipeline()
result = pipeline.process(
file_infos,
user_id=user_id,
base_path=STORAGE_BASE
)
print(f"✅ Pipeline processing complete: {result}")
except Exception as e:
print(f"⚠️ Pipeline error: {e}")
traceback.print_exc()
result = {"chunks": 0, "tables": 0}
# Re-detect currency after rebuild
detected_currency = detect_and_save_user_currency(user_id, paths["files"], STORAGE_BASE)
print(f"💰 Detected currency: {detected_currency}")
return {
"success": True,
"message": "🔄 Successfully retrained! AI now knows ONLY your current files.",
"files_processed": len(files),
"files": [f.name for f in files],
"chunks_created": result.get("chunks", 0),
"tables_processed": result.get("tables", 0),
"detectedCurrency": detected_currency
}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{user_id}/currency")
async def get_currency(user_id: str):
"""Get detected currency for user's files"""
try:
from utils.currency import load_currency_metadata
paths = get_user_paths(user_id)
# Try to load stored currency
stored = load_currency_metadata(user_id, STORAGE_BASE)
if stored:
return {
"currency": stored,
"source": "stored",
"symbol": CURRENCY_CONFIG.get(stored, {}).get('symbol', '$')
}
# Detect from files
detected = detect_and_save_user_currency(user_id, paths["files"], STORAGE_BASE)
return {
"currency": detected,
"source": "detected",
"symbol": CURRENCY_CONFIG.get(detected, {}).get('symbol', '$')
}
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{user_id}/currency/{currency_code}")
async def set_currency(user_id: str, currency_code: str):
"""Manually set currency for user's data"""
try:
currency_code = currency_code.upper()
if currency_code not in CURRENCY_CONFIG:
raise HTTPException(
status_code=400,
detail=f"Invalid currency: {currency_code}. Supported: {', '.join(CURRENCY_CONFIG.keys())}"
)
save_currency_metadata(user_id, currency_code, STORAGE_BASE)
return {
"success": True,
"currency": currency_code,
"symbol": CURRENCY_CONFIG[currency_code]['symbol'],
"message": f"Currency set to {currency_code}"
}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{user_id}/currency/detect")
async def detect_currency_endpoint(user_id: str):
"""Re-detect currency from uploaded files"""
try:
paths = get_user_paths(user_id)
if not paths["files"].exists() or not list(paths["files"].glob('*')):
raise HTTPException(status_code=400, detail="No files uploaded. Upload files first.")
detected = detect_and_save_user_currency(user_id, paths["files"], STORAGE_BASE)
return {
"success": True,
"currency": detected,
"symbol": CURRENCY_CONFIG.get(detected, {}).get('symbol', '$'),
"message": f"Detected currency: {detected}"
}
except HTTPException:
raise
except Exception as e:
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{user_id}/{file_id}/columns")
async def get_file_columns(
user_id: str,
file_id: str,
x_user_id: Optional[str] = Header(None, alias="X-User-ID"),
authorization: Optional[str] = Header(None, alias="Authorization")
):
"""Get columns of a file (handles CSV and Excel safely)"""
try:
authenticated_user = await get_user_id_from_headers(x_user_id, authorization)
if authenticated_user and authenticated_user != user_id:
user_id = authenticated_user
paths = get_user_paths(user_id)
file_path = paths["files"] / file_id
if not file_path.exists():
raise HTTPException(status_code=404, detail="File not found")
import pandas as pd
if file_path.suffix.lower() == '.csv':
df = pd.read_csv(file_path, nrows=0)
elif file_path.suffix.lower() in ['.xlsx', '.xls']:
df = pd.read_excel(file_path, nrows=0)
else:
return {"success": False, "error": "Unsupported file format for column extraction"}
return {"success": True, "columns": list(df.columns)}
except Exception as e:
return {"success": False, "error": str(e)}
@router.get("/{user_id}/{file_id}/download")
async def download_file(user_id: str, file_id: str):
"""Download a specific file, intercepting Live Connections to return real-time CSV"""
try:
from fastapi.responses import FileResponse, StreamingResponse
# ✨ MAGIC: Intercept Live Connections
if file_id.startswith("LIVE_") and file_id.endswith(".csv"):
conn_id = file_id.replace("LIVE_", "").replace(".csv", "")
from database.db import AsyncSessionLocal
from database.orm import DataConnection
from sqlalchemy import select
import pandas as pd
import psycopg2
import io
async with AsyncSessionLocal() as db:
result = await db.execute(select(DataConnection).where(DataConnection.id == conn_id))
conn = result.scalar_one_or_none()
if not conn:
raise HTTPException(status_code=404, detail="Live Connection not found")
try:
import urllib.parse
safe_creds = urllib.parse.quote_plus(conn.credentials)
conn_str = f"postgresql://postgres:{safe_creds}@{conn.host}/{conn.database_name}"
# Offload the blocking DB read and CSV generation to a background thread
import asyncio
loop = asyncio.get_running_loop()
def fetch_to_csv():
with psycopg2.connect(conn_str) as pg_conn:
df = pd.read_sql(f"SELECT * FROM {conn.target_table}", pg_conn)
csv_data = df.to_csv(index=False)
return csv_data
csv_string = await loop.run_in_executor(None, fetch_to_csv)
return StreamingResponse(
io.StringIO(csv_string),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={file_id}"}
)
except Exception as db_e:
print(f"⚠️ Live download failed: {db_e}")
raise HTTPException(status_code=500, detail=f"Failed to fetch live data: {db_e}")
# Normal file download
paths = get_user_paths(user_id)
file_path = paths["files"] / file_id
if not file_path.exists():
# A metric-only PostgreSQL/Snowflake stream has no raw records yet.
# Download a valid checkpoint CSV instead of an opaque 404.
telemetry_path = paths["files"] / f"{Path(file_id).stem}.telemetry.json"
if telemetry_path.exists() and file_id.startswith("live_stream_"):
import io
state = json.loads(telemetry_path.read_text(encoding="utf-8"))
row = {
"total_rows": state.get("total_rows", 0),
"rows_per_sec": state.get("rows_per_sec", 0),
"timestamp": state.get("timestamp", ""),
"status": state.get("status", ""),
}
csv_data = ",".join(row.keys()) + "\n" + ",".join(str(v).replace(",", " ") for v in row.values()) + "\n"
return StreamingResponse(
io.StringIO(csv_data), media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename={file_id}"},
)
raise HTTPException(status_code=404, detail="File not found")
return FileResponse(
path=str(file_path),
filename=file_id,
media_type='application/octet-stream'
)
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# ============================================================================
# GOOGLE SHEETS IMPORT ENDPOINT
# ============================================================================
@router.post("/{user_id}/import-google-sheet")
async def import_google_sheet(
user_id: str,
sheet_url: str = Query(..., description="Google Sheets URL or ID"),
sheet_name: Optional[str] = Query(None, description="Optional sheet/tab name")
):
"""
Import data from a public Google Sheet
The sheet must be accessible via link (Anyone with the link can view)
"""
try:
from integrations.google_sheets import import_google_sheet as gs_import, extract_sheet_id
from ingestion.pipeline import IngestionPipeline
# Validate sheet ID
sheet_id = extract_sheet_id(sheet_url)
if not sheet_id:
raise HTTPException(status_code=400, detail="Invalid Google Sheets URL")
# Import sheet to DataFrame
print(f"📊 Importing Google Sheet: {sheet_id}")
df = await gs_import(sheet_url, sheet_name)
if df.empty:
raise HTTPException(status_code=400, detail="Sheet is empty or could not be read")
print(f"📊 Sheet loaded: {len(df)} rows, {len(df.columns)} columns")
# Save as CSV to user's files directory
paths = get_user_paths(user_id)
temp_filename = f"google_sheet_{sheet_id[:8]}.csv"
temp_path = paths["files"] / temp_filename
df.to_csv(temp_path, index=False)
print(f"📊 Saved to: {temp_path}")
# Run ingestion pipeline using process method (for files already on disk)
pipeline = IngestionPipeline()
file_info = [{
"id": f"gs_{sheet_id[:8]}",
"name": temp_filename,
"size": temp_path.stat().st_size if temp_path.exists() else 0,
"type": "text/csv"
}]
pipeline.process(file_info, user_id, paths["files"].parent)
# Invalidate cache
invalidate_user_cache(user_id)
return {
"success": True,
"message": "Google Sheet imported successfully",
"filename": temp_filename,
"rows": len(df),
"columns": list(df.columns),
"sampleData": df.head(3).to_dict(orient='records')
}
except HTTPException:
raise
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=f"Failed to import: {str(e)}")
@router.get("/{user_id}/preview-google-sheet")
async def preview_google_sheet(
user_id: str,
sheet_url: str = Query(..., description="Google Sheets URL or ID")
):
"""
Preview a Google Sheet before importing
Returns column names, row count, and sample data
"""
try:
from integrations.google_sheets import get_sheet_info
info = await get_sheet_info(sheet_url)
if not info.get("success"):
raise HTTPException(
status_code=400,
detail=info.get("error", "Failed to read sheet")
)
return info
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/{user_id}/join-suggestions")
async def get_join_suggestions(
user_id: str,
file1: str = Query(..., description="First filename"),
file2: str = Query(..., description="Second filename")
):
"""
Suggests possible join keys between two files.
"""
try:
paths = get_user_paths(user_id)
file1_path = paths["files"] / sanitize_filename(file1)
file2_path = paths["files"] / sanitize_filename(file2)
if not file1_path.exists() or not file2_path.exists():
raise HTTPException(status_code=404, detail="One or both files not found")
import pandas as pd
df1 = pd.read_csv(file1_path, nrows=1000)
df2 = pd.read_csv(file2_path, nrows=1000)
from core.multi_dataset_engine import MultiDatasetEngine
suggestions = MultiDatasetEngine.infer_joins(df1, df2, file1, file2)
return {"suggestions": suggestions}
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
from pydantic import BaseModel
class JoinRequest(BaseModel):
file1: str
file2: str
left_on: str
right_on: str
how: str = "left"
output_filename: str = "joined_dataset.csv"
@router.post("/{user_id}/join")
async def join_datasets(
user_id: str,
req: JoinRequest
):
"""
Joins two datasets and saves the result as a new file.
"""
try:
paths = get_user_paths(user_id)
file1_path = paths["files"] / sanitize_filename(req.file1)
file2_path = paths["files"] / sanitize_filename(req.file2)
if not file1_path.exists() or not file2_path.exists():
raise HTTPException(status_code=404, detail="One or both files not found")
import pandas as pd
df1 = pd.read_csv(file1_path)
df2 = pd.read_csv(file2_path)
from core.multi_dataset_engine import MultiDatasetEngine
merged_df = MultiDatasetEngine.join_datasets(
df1, df2, req.left_on, req.right_on, req.how
)
# Save output
output_name = sanitize_filename(req.output_filename)
if not output_name.endswith('.csv'):
output_name += '.csv'
output_path = paths["files"] / output_name
merged_df.to_csv(output_path, index=False)
# Invalidate cache
invalidate_user_cache(user_id)
# Register in metadata
pipeline = IngestionPipeline()
file_info = [{
"filename": output_name,
"original_name": output_name,
"path": str(output_path),
"size": output_path.stat().st_size,
"type": "text/csv"
}]
pipeline.process(file_info, user_id, paths["files"].parent)
return {
"success": True,
"message": "Datasets joined successfully",
"filename": output_name,
"rows": len(merged_df),
"columns": list(merged_df.columns)
}
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))