from fastapi import APIRouter, HTTPException, BackgroundTasks, status from fastapi.responses import FileResponse from pydantic import BaseModel, Field from typing import List, Optional, Dict, Any from pif_compiler.classes.main_workflow import receive_order, process_order_pipeline, retry_order, trigger_pipeline, Project from pif_compiler.functions.db_utils import db_connect, get_ordine_by_id, get_all_ordini, delete_ordine from pif_compiler.functions.common_func import generate_project_source_pdfs, create_sources_zip from pif_compiler.functions.common_log import get_logger logger = get_logger() router = APIRouter() # ==================== REQUEST / RESPONSE MODELS ==================== class OrderIngredientInput(BaseModel): inci: Optional[str] = None cas: Optional[str] = None percentage: float = 0.0 is_colorante: bool = False skip_tox: bool = False class OrderCreateRequest(BaseModel): client_name: str = Field(..., description="Nome del cliente") product_name: str = Field(..., description="Nome del prodotto cosmetico") preset_esposizione: str = Field(..., description="Nome del preset di esposizione") ingredients: List[OrderIngredientInput] = Field(..., description="Lista ingredienti") class Config: json_schema_extra = { "example": { "client_name": "Cosmetica Italia srl", "product_name": "Crema 'Cremosa'", "preset_esposizione": "Test Preset", "ingredients": [ {"inci": "AQUA", "cas": "", "percentage": 90, "is_colorante": False, "skip_tox": True}, {"inci": None, "cas": "56-81-5", "percentage": 6, "is_colorante": False, "skip_tox": False}, {"inci": None, "cas": "9007-16-3", "percentage": 3, "is_colorante": False, "skip_tox": False}, {"inci": None, "cas": "JYY-807", "percentage": 1, "is_colorante": True, "skip_tox": False} ] } } class OrderCreateResponse(BaseModel): success: bool id_ordine: Optional[int] = None message: Optional[str] = None error: Optional[str] = None # ==================== ROUTES ==================== @router.post("/orders/create", response_model=OrderCreateResponse, tags=["Orders"]) async def create_order(request: OrderCreateRequest, background_tasks: BackgroundTasks): """ Crea un nuovo ordine e avvia l'elaborazione in background. Il JSON viene salvato su MongoDB, il record su PostgreSQL (stato=RICEVUTO). L'arricchimento degli ingredienti avviene in background. """ logger.info(f"Nuovo ordine ricevuto: cliente={request.client_name}, prodotto={request.product_name}") try: raw_json = request.model_dump() id_ordine = receive_order(raw_json) if id_ordine is None: return OrderCreateResponse( success=False, error="Errore nel salvataggio dell'ordine" ) # Avvia l'elaborazione in background background_tasks.add_task(process_order_pipeline) logger.info(f"Ordine {id_ordine} creato, elaborazione avviata in background") return OrderCreateResponse( success=True, id_ordine=id_ordine, message=f"Ordine {id_ordine} ricevuto. Elaborazione avviata in background." ) except Exception as e: logger.error(f"Errore creazione ordine: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.post("/orders/retry/{id_ordine}", response_model=OrderCreateResponse, tags=["Orders"]) async def retry_failed_order(id_ordine: int, background_tasks: BackgroundTasks): """ Resetta un ordine in stato ERRORE a RICEVUTO e rilancia la pipeline. """ logger.info(f"Retry ordine {id_ordine}") try: success = retry_order(id_ordine) if not success: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Ordine {id_ordine} non trovato o non in stato ERRORE" ) background_tasks.add_task(process_order_pipeline) return OrderCreateResponse( success=True, id_ordine=id_ordine, message=f"Ordine {id_ordine} resettato. Rielaborazione avviata in background." ) except HTTPException: raise except Exception as e: logger.error(f"Errore retry ordine {id_ordine}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.post("/orders/trigger-pipeline", response_model=OrderCreateResponse, tags=["Orders"]) async def manual_trigger_pipeline(background_tasks: BackgroundTasks): """ Lancia manualmente la pipeline di elaborazione. Processa il prossimo ordine pendente (più vecchio con stato RICEVUTO). """ logger.info("Trigger manuale pipeline") try: background_tasks.add_task(trigger_pipeline) return OrderCreateResponse( success=True, message="Pipeline avviata in background." ) except Exception as e: logger.error(f"Errore trigger pipeline: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.get("/orders/export/{id_ordine}", tags=["Orders"]) async def export_order_excel(id_ordine: int): """ Genera e scarica il file Excel per un ordine completato. L'ordine deve avere un uuid_progetto associato (stato >= ARRICCHITO). """ logger.info(f"Export Excel per ordine {id_ordine}") try: # Recupera l'ordine dal DB row = get_ordine_by_id(id_ordine) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Ordine {id_ordine} non trovato" ) uuid_progetto = row[4] # uuid_progetto if not uuid_progetto: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Ordine {id_ordine} non ha un progetto associato (elaborazione non completata?)" ) # Recupera il progetto da MongoDB from bson import ObjectId collection = db_connect(collection_name='projects') doc = collection.find_one({"_id": ObjectId(uuid_progetto)}) if not doc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Progetto {uuid_progetto} non trovato in MongoDB" ) doc.pop("_id", None) project = Project(**doc) # Genera Excel output_path = project.export_excel() logger.info(f"Excel generato per ordine {id_ordine}: {output_path}") return FileResponse( path=output_path, filename=f"progetto_{id_ordine}.xlsx", media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" ) except HTTPException: raise except Exception as e: logger.error(f"Errore export Excel ordine {id_ordine}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.get("/orders/export-sources/{id_ordine}", tags=["Orders"]) async def export_order_sources(id_ordine: int): """ Genera i PDF delle fonti per ogni ingrediente di un ordine e li restituisce in un archivio ZIP. Include: PDF tossicologico del best_case (CAS_source.pdf) + PDF COSING (CAS_cosing.pdf). """ logger.info(f"Export fonti PDF per ordine {id_ordine}") try: # Recupera l'ordine dal DB row = get_ordine_by_id(id_ordine) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Ordine {id_ordine} non trovato" ) uuid_progetto = row[4] if not uuid_progetto: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Ordine {id_ordine} non ha un progetto associato" ) # Recupera il progetto da MongoDB from bson import ObjectId collection = db_connect(collection_name='projects') doc = collection.find_one({"_id": ObjectId(uuid_progetto)}) if not doc: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Progetto {uuid_progetto} non trovato in MongoDB" ) doc.pop("_id", None) project = Project(**doc) # Genera i PDF delle fonti pdf_paths = await generate_project_source_pdfs(project) if not pdf_paths: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="Nessuna fonte (tox/COSING) disponibile per questo progetto" ) # Crea ZIP import os os.makedirs("exports", exist_ok=True) zip_path = f"exports/fonti_ordine_{id_ordine}.zip" create_sources_zip(pdf_paths, zip_path) logger.info(f"ZIP fonti generato per ordine {id_ordine}: {zip_path} ({len(pdf_paths)} PDF)") return FileResponse( path=zip_path, filename=f"fonti_ordine_{id_ordine}.zip", media_type="application/zip" ) except HTTPException: raise except Exception as e: logger.error(f"Errore export fonti ordine {id_ordine}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.get("/orders/list", tags=["Orders"]) async def list_orders(): """ Recupera la lista di tutti gli ordini con info cliente, compilatore e stato. Per ciascun ordine recupera product_name dal documento MongoDB. """ logger.info("Richiesta lista ordini") try: rows = get_all_ordini() orders = [] orders_col = db_connect(collection_name='orders') for row in rows: id_ordine, uuid_ordine, uuid_progetto, data_ordine, stato_ordine, note, \ nome_cliente, nome_compilatore, nome_stato = row # Recupera product_name da MongoDB product_name = None if uuid_ordine: try: from bson import ObjectId doc = orders_col.find_one({"_id": ObjectId(uuid_ordine)}, {"product_name": 1}) if doc: product_name = doc.get("product_name") except Exception: pass orders.append({ "id_ordine": id_ordine, "uuid_ordine": uuid_ordine, "uuid_progetto": uuid_progetto, "data_ordine": data_ordine.isoformat() if data_ordine else None, "stato_ordine": stato_ordine, "note": note, "nome_cliente": nome_cliente, "nome_compilatore": nome_compilatore, "nome_stato": nome_stato, "product_name": product_name, }) return {"success": True, "data": orders} except Exception as e: logger.error(f"Errore lista ordini: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.get("/orders/detail/{id_ordine}", tags=["Orders"]) async def get_order_detail(id_ordine: int): """ Recupera il dettaglio completo di un ordine: dati PostgreSQL + documento MongoDB. Include product_name, preset, lista ingredienti dal JSON originale. """ logger.info(f"Dettaglio ordine {id_ordine}") try: row = get_ordine_by_id(id_ordine) if not row: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Ordine {id_ordine} non trovato" ) id_ord, id_cliente, id_compilatore, uuid_ordine, uuid_progetto, \ data_ordine, stato_ordine, note = row # Recupera nomi da PostgreSQL from pif_compiler.functions.db_utils import postgres_connect conn = postgres_connect() with conn.cursor() as cur: nome_cliente = None if id_cliente: cur.execute("SELECT nome_cliente FROM clienti WHERE id_cliente = %s", (id_cliente,)) r = cur.fetchone() nome_cliente = r[0] if r else None nome_compilatore = None if id_compilatore: cur.execute("SELECT nome_compilatore FROM compilatori WHERE id_compilatore = %s", (id_compilatore,)) r = cur.fetchone() nome_compilatore = r[0] if r else None cur.execute("SELECT nome_stato FROM stati_ordini WHERE id_stato = %s", (stato_ordine,)) r = cur.fetchone() nome_stato = r[0] if r else None conn.close() # Recupera dati dal documento MongoDB ordine product_name = None preset_esposizione = None ingredients = [] if uuid_ordine: from bson import ObjectId orders_col = db_connect(collection_name='orders') try: doc = orders_col.find_one({"_id": ObjectId(uuid_ordine)}) if doc: product_name = doc.get("product_name") preset_esposizione = doc.get("preset_esposizione") ingredients = doc.get("ingredients", []) except Exception: pass return { "success": True, "order": { "id_ordine": id_ord, "uuid_ordine": uuid_ordine, "uuid_progetto": uuid_progetto, "data_ordine": data_ordine.isoformat() if data_ordine else None, "stato_ordine": stato_ordine, "nome_stato": nome_stato, "note": note, "nome_cliente": nome_cliente, "nome_compilatore": nome_compilatore, "product_name": product_name, "preset_esposizione": preset_esposizione, "ingredients": ingredients, } } except HTTPException: raise except Exception as e: logger.error(f"Errore dettaglio ordine {id_ordine}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" ) @router.delete("/orders/{id_ordine}", tags=["Orders"]) async def remove_order(id_ordine: int): """ Elimina un ordine e tutti i dati correlati (progetto, lineage, documenti MongoDB). """ logger.info(f"Eliminazione ordine {id_ordine}") try: success = delete_ordine(id_ordine) if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Ordine {id_ordine} non trovato o errore nell'eliminazione" ) return {"success": True, "message": f"Ordine {id_ordine} eliminato con successo"} except HTTPException: raise except Exception as e: logger.error(f"Errore eliminazione ordine {id_ordine}: {str(e)}", exc_info=True) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Errore interno: {str(e)}" )