coming together

This commit is contained in:
HotSwapp
2025-08-13 18:53:35 -05:00
parent acc5155bf7
commit 5111079149
51 changed files with 14457 additions and 588 deletions

281
app/api/flexible.py Normal file
View File

@@ -0,0 +1,281 @@
"""
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}\"",
},
)