from datetime import datetime from uuid import uuid4 import base64 import os import json import logging from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query from sqlalchemy.orm import Session from typing import Optional, Literal, Dict, Any from core.celery_app import celery_app from core.config import settings from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource from .tasks import ( scan_file, insert_valid_rows, IMPORT_FILE_KEY_PREFIX, IMPORT_META_KEY_PREFIX, IMPORT_REDIS_TTL, ) from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest router = APIRouter() logger = logging.getLogger(__name__) def _get_redis(): """Redis client (same broker as Celery so worker can read).""" import redis url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0")) return redis.Redis.from_url(url, decode_responses=False) @router.post("/upload/{model_target}", response_model=ImportJobResponse) async def upload_import_file( model_target: Literal["invoice_header", "invoice_details"], file: UploadFile = File(...), footer_config: Optional[str] = Form(None), # JSON string with settings template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas company_id: int = Query(..., description="Company ID"), # Required for context operation_type: Optional[str] = Query("imp"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Step 1: Upload CSV, save to temp, trigger scan task. Si se envĂ­a template_id, solo se leen las columnas de esa plantilla. """ # 1. Validate Access & Get Tenant try: tenant_id = validate_access_to_resource(db, company_id, current_user) except Exception as e: logger.error(f"Access validation failed: {e}") raise HTTPException(status_code=403, detail="Invalid company access") if not file.filename.endswith(".csv"): raise HTTPException(status_code=400, detail="Only .csv files allowed") job_id = str(uuid4()) contents = await file.read() meta_data = { "tenant_id": tenant_id, "company_id": company_id, "user_id": current_user.get("id"), "footer_config": footer_config, "operation_type": operation_type, "template_id": template_id, } # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) try: redis_client = _get_redis() redis_client.set( f"{IMPORT_FILE_KEY_PREFIX}{job_id}", base64.b64encode(contents), ex=IMPORT_REDIS_TTL, ) redis_client.set( f"{IMPORT_META_KEY_PREFIX}{job_id}", json.dumps(meta_data).encode("utf-8"), ex=IMPORT_REDIS_TTL, ) except Exception as e: logger.error(f"Redis store error: {e}") raise HTTPException(status_code=500, detail="Failed to queue file for processing.") # Optional: also write to local disk (e.g. for same-machine worker or debugging) try: upload_dir = os.path.join(os.getcwd(), "uploads", "temp") os.makedirs(upload_dir, exist_ok=True) file_path = os.path.join(upload_dir, f"{job_id}.csv") meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") with open(file_path, "wb") as f: f.write(contents) with open(meta_path, "w") as f: json.dump(meta_data, f) except Exception as e: logger.warning(f"Local file save failed (worker will use Redis): {e}") # Trigger Celery Task (Async). Worker loads file from Redis. scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id) return ImportJobResponse( job_id=job_id, status="queued", message="File uploaded. Scanning started." ) @router.get("/{job_id}/status") async def get_import_status(job_id: str): """ Poll to get progress or final report. Always returns an object with "status". """ task_result = celery_app.AsyncResult(job_id) if task_result.state == "PENDING": return {"status": "processing", "progress": 0} if task_result.state == "PROGRESS": return { "status": "processing", "progress": (task_result.info or {}).get("current", 0), "total": (task_result.info or {}).get("total", 0), } if task_result.state == "SUCCESS": result = task_result.result if isinstance(result, dict) and "status" in result: return result return {"status": "finished", "result": result} # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) logger.warning("Import task %s failed: state=%s", job_id, task_result.state) err_msg = None tb = getattr(task_result, "traceback", None) if tb: logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb) if tb and isinstance(tb, str): lines = [l.strip() for l in tb.strip().split("\n") if l.strip()] if lines: err_msg = lines[-1] if not err_msg and len(lines) > 1: err_msg = lines[-2] + " " + (lines[-1] or "") if not err_msg: try: exc = task_result.get(propagate=False) if exc is not None: err_msg = str(exc) except Exception: pass if not err_msg: result = getattr(task_result, "result", None) info = getattr(task_result, "info", None) if result is not None and not isinstance(result, dict): err_msg = str(result) elif isinstance(result, dict) and (result.get("error") or result.get("message")): err_msg = result.get("error") or result.get("message") if not err_msg and isinstance(info, str): err_msg = info elif not err_msg and isinstance(info, dict) and "error" in info: err_msg = str(info["error"]) if not err_msg: err_msg = "Task failed" return {"status": "failed", "error": err_msg} @router.post("/{job_id}/commit") async def commit_import_job(job_id: str, body: CommitRequest): """ Step 2: User confirms import. Trigger bulk insert. """ task = insert_valid_rows.delay(job_id, body.model_target) return { "status": "committing", "message": "Bulk insert started.", "commit_job_id": task.id }