""" Flexible Imports admin API: list, filter, and export unmapped rows captured during CSV imports. """ from typing import Optional, Dict, Any, List from datetime import datetime import csv import io from fastapi import APIRouter, Depends, Query, HTTPException from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import func, or_, cast, String from app.database.base import get_db from app.auth.security import get_admin_user from app.models.flexible import FlexibleImport router = APIRouter(prefix="/flexible", tags=["flexible"]) @router.get("/imports") async def list_flexible_imports( file_type: Optional[str] = Query(None, description="Filter by CSV file type (e.g., FILES.csv)"), target_table: Optional[str] = Query(None, description="Filter by target model table name"), q: Optional[str] = Query(None, description="Quick text search across file type, target table, and unmapped data"), has_keys: Optional[List[str]] = Query( None, description="Filter rows where extra_data (or its 'unmapped' payload) contains these keys. Repeat param for multiple keys.", ), skip: int = Query(0, ge=0), limit: int = Query(50, ge=1, le=500), db: Session = Depends(get_db), current_user=Depends(get_admin_user), ): """List flexible import rows with optional filtering, quick search, and pagination.""" query = db.query(FlexibleImport) if file_type: query = query.filter(FlexibleImport.file_type == file_type) if target_table: query = query.filter(FlexibleImport.target_table == target_table) if q: pattern = f"%{q.strip()}%" # Search across file_type, target_table, and serialized JSON extra_data query = query.filter( or_( FlexibleImport.file_type.ilike(pattern), FlexibleImport.target_table.ilike(pattern), cast(FlexibleImport.extra_data, String).ilike(pattern), ) ) # Filter by key presence inside JSON payload by string matching of the serialized JSON # This is DB-agnostic and works across SQLite/Postgres, though not as precise as JSON operators. if has_keys: for k in [k for k in has_keys if k is not None and str(k).strip() != ""]: key = str(k).strip() # Look for the JSON key token followed by a colon, e.g. "key": query = query.filter(cast(FlexibleImport.extra_data, String).ilike(f'%"{key}":%')) total = query.count() items = ( query.order_by(FlexibleImport.id.desc()) .offset(skip) .limit(limit) .all() ) def serialize(item: FlexibleImport) -> Dict[str, Any]: return { "id": item.id, "file_type": item.file_type, "target_table": item.target_table, "primary_key_field": item.primary_key_field, "primary_key_value": item.primary_key_value, "extra_data": item.extra_data, } return { "total": total, "skip": skip, "limit": limit, "items": [serialize(i) for i in items], } @router.get("/options") async def flexible_options( db: Session = Depends(get_db), current_user=Depends(get_admin_user), ): """Return distinct file types and target tables for filter dropdowns.""" file_types: List[str] = [ ft for (ft,) in db.query(func.distinct(FlexibleImport.file_type)).order_by(FlexibleImport.file_type.asc()).all() if ft is not None ] target_tables: List[str] = [ tt for (tt,) in db.query(func.distinct(FlexibleImport.target_table)).order_by(FlexibleImport.target_table.asc()).all() if tt is not None and tt != "" ] return {"file_types": file_types, "target_tables": target_tables} @router.get("/export") async def export_unmapped_csv( file_type: Optional[str] = Query(None, description="Filter by CSV file type (e.g., FILES.csv)"), target_table: Optional[str] = Query(None, description="Filter by target model table name"), has_keys: Optional[List[str]] = Query( None, description="Filter rows where extra_data (or its 'unmapped' payload) contains these keys. Repeat param for multiple keys.", ), db: Session = Depends(get_db), current_user=Depends(get_admin_user), ): """Export unmapped rows as CSV for review. Includes basic metadata columns and unmapped fields. If FlexibleImport.extra_data contains a nested 'unmapped' dict, those keys are exported. Otherwise, all keys of extra_data are exported. """ query = db.query(FlexibleImport) if file_type: query = query.filter(FlexibleImport.file_type == file_type) if target_table: query = query.filter(FlexibleImport.target_table == target_table) if has_keys: for k in [k for k in has_keys if k is not None and str(k).strip() != ""]: key = str(k).strip() query = query.filter(cast(FlexibleImport.extra_data, String).ilike(f'%"{key}":%')) rows: List[FlexibleImport] = query.order_by(FlexibleImport.id.asc()).all() if not rows: raise HTTPException(status_code=404, detail="No matching flexible imports to export") # Determine union of unmapped keys across all rows unmapped_keys: List[str] = [] key_set = set() for r in rows: data = r.extra_data or {} payload = data.get("unmapped") if isinstance(data, dict) and isinstance(data.get("unmapped"), dict) else data if isinstance(payload, dict): for k in payload.keys(): if k not in key_set: key_set.add(k) unmapped_keys.append(k) # Prepare CSV meta_headers = [ "id", "file_type", "target_table", "primary_key_field", "primary_key_value", ] fieldnames = meta_headers + unmapped_keys output = io.StringIO() writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() for r in rows: row_out: Dict[str, Any] = { "id": r.id, "file_type": r.file_type, "target_table": r.target_table or "", "primary_key_field": r.primary_key_field or "", "primary_key_value": r.primary_key_value or "", } data = r.extra_data or {} payload = data.get("unmapped") if isinstance(data, dict) and isinstance(data.get("unmapped"), dict) else data if isinstance(payload, dict): for k in unmapped_keys: v = payload.get(k) # Normalize lists/dicts to JSON strings for CSV safety if isinstance(v, (dict, list)): try: import json as _json row_out[k] = _json.dumps(v, ensure_ascii=False) except Exception: row_out[k] = str(v) else: row_out[k] = v if v is not None else "" writer.writerow(row_out) output.seek(0) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename_parts = ["flexible_unmapped"] if file_type: filename_parts.append(file_type.replace("/", "-").replace(" ", "_")) if target_table: filename_parts.append(target_table.replace("/", "-").replace(" ", "_")) filename = "_".join(filename_parts) + f"_{timestamp}.csv" return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=\"{filename}\"", }, ) @router.get("/export/{row_id}") async def export_single_row_csv( row_id: int, db: Session = Depends(get_db), current_user=Depends(get_admin_user), ): """Export a single flexible import row as CSV. Includes metadata columns plus keys from the row's unmapped payload. If FlexibleImport.extra_data contains a nested 'unmapped' dict, those keys are exported; otherwise, all keys of extra_data are exported. """ row: Optional[FlexibleImport] = ( db.query(FlexibleImport).filter(FlexibleImport.id == row_id).first() ) if not row: raise HTTPException(status_code=404, detail="Flexible import row not found") data = row.extra_data or {} payload = ( data.get("unmapped") if isinstance(data, dict) and isinstance(data.get("unmapped"), dict) else data ) unmapped_keys: List[str] = [] if isinstance(payload, dict): for k in payload.keys(): unmapped_keys.append(k) meta_headers = [ "id", "file_type", "target_table", "primary_key_field", "primary_key_value", ] fieldnames = meta_headers + unmapped_keys output = io.StringIO() writer = csv.DictWriter(output, fieldnames=fieldnames) writer.writeheader() row_out: Dict[str, Any] = { "id": row.id, "file_type": row.file_type, "target_table": row.target_table or "", "primary_key_field": row.primary_key_field or "", "primary_key_value": row.primary_key_value or "", } if isinstance(payload, dict): for k in unmapped_keys: v = payload.get(k) if isinstance(v, (dict, list)): try: import json as _json row_out[k] = _json.dumps(v, ensure_ascii=False) except Exception: row_out[k] = str(v) else: row_out[k] = v if v is not None else "" writer.writerow(row_out) output.seek(0) timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") filename = ( f"flexible_row_{row.id}_{row.file_type.replace('/', '-').replace(' ', '_')}_{timestamp}.csv" if row.file_type else f"flexible_row_{row.id}_{timestamp}.csv" ) return StreamingResponse( iter([output.getvalue()]), media_type="text/csv", headers={ "Content-Disposition": f"attachment; filename=\"{filename}\"", }, )