|
@@ -377,7 +377,7 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
|
|
|
from starlette.background import BackgroundTask
|
|
from starlette.background import BackgroundTask
|
|
|
|
|
|
|
|
from ..metrics_constants import METRIC_KEYS as _METRIC_KEYS # noqa: F401
|
|
from ..metrics_constants import METRIC_KEYS as _METRIC_KEYS # noqa: F401
|
|
|
- from afmcore.xlsx_report import build_export_columns, write_summary_xlsx
|
|
|
|
|
|
|
+ from afmcore.xlsx_report import write_summary_xlsx
|
|
|
|
|
|
|
|
plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
|
|
plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
|
|
|
if not plan:
|
|
if not plan:
|
|
@@ -392,7 +392,50 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
|
|
|
if not results:
|
|
if not results:
|
|
|
raise HTTPException(status_code=400, detail="No simulation results to export")
|
|
raise HTTPException(status_code=400, detail="No simulation results to export")
|
|
|
|
|
|
|
|
- # Parameter names + Chinese labels from the plan's scan variables.
|
|
|
|
|
|
|
+ col_keys, col_categories, col_headers, data_rows = _collect_export_rows(
|
|
|
|
|
+ plan, results
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
+ tmp = tempfile.NamedTemporaryFile(
|
|
|
|
|
+ prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
|
|
|
|
|
+ )
|
|
|
|
|
+ tmp.close()
|
|
|
|
|
+ write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
|
|
|
|
|
+
|
|
|
|
|
+ plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
|
|
|
|
|
+ # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
|
|
|
|
|
+ ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
|
|
|
|
|
+ utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
|
|
|
|
|
+ disposition = (
|
|
|
|
|
+ f"attachment; filename=\"{ascii_fallback}\"; "
|
|
|
|
|
+ f"filename*=UTF-8''{quote(utf8_name)}"
|
|
|
|
|
+ )
|
|
|
|
|
+ return FileResponse(
|
|
|
|
|
+ tmp.name,
|
|
|
|
|
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
+ headers={"Content-Disposition": disposition},
|
|
|
|
|
+ background=BackgroundTask(os_remove_quiet, tmp.name),
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def os_remove_quiet(path: str) -> None:
|
|
|
|
|
+ """Best-effort temp-file cleanup after FileResponse streaming."""
|
|
|
|
|
+ import os
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ os.remove(path)
|
|
|
|
|
+ except OSError:
|
|
|
|
|
+ pass
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _collect_export_rows(plan, results):
|
|
|
|
|
+ """Shared row/column assembly for xlsx and csv exports.
|
|
|
|
|
+
|
|
|
|
|
+ Returns (col_keys, col_categories, col_headers, data_rows).
|
|
|
|
|
+ """
|
|
|
|
|
+ from afmcore.xlsx_report import build_export_columns
|
|
|
|
|
+
|
|
|
param_labels: dict = {}
|
|
param_labels: dict = {}
|
|
|
try:
|
|
try:
|
|
|
plan_dict = plan.get_plan_dict()
|
|
plan_dict = plan.get_plan_dict()
|
|
@@ -426,47 +469,62 @@ def export_plan_results_xlsx(plan_id: int, db: Session = Depends(get_db)):
|
|
|
row.update(metrics)
|
|
row.update(metrics)
|
|
|
data_rows.append(row)
|
|
data_rows.append(row)
|
|
|
|
|
|
|
|
- # Keep only metric keys the platform knows about; unknown keys are still
|
|
|
|
|
- # exported via the CAT_OTHER fallback inside build_export_columns.
|
|
|
|
|
present_metrics = {k for k in present_metrics if isinstance(k, str)}
|
|
present_metrics = {k for k in present_metrics if isinstance(k, str)}
|
|
|
-
|
|
|
|
|
col_keys, col_categories, col_headers = build_export_columns(
|
|
col_keys, col_categories, col_headers = build_export_columns(
|
|
|
param_names, sorted(present_metrics), param_labels
|
|
param_names, sorted(present_metrics), param_labels
|
|
|
)
|
|
)
|
|
|
|
|
+ return col_keys, col_categories, col_headers, data_rows
|
|
|
|
|
|
|
|
- ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
|
|
- tmp = tempfile.NamedTemporaryFile(
|
|
|
|
|
- prefix=f"scan_results_{ts}_", suffix=".xlsx", delete=False
|
|
|
|
|
|
|
+
|
|
|
|
|
+@router.get("/{plan_id}/export-csv")
|
|
|
|
|
+def export_plan_results_csv(plan_id: int, db: Session = Depends(get_db)):
|
|
|
|
|
+ """Export plan simulation results as CSV (same columns as the xlsx export).
|
|
|
|
|
+
|
|
|
|
|
+ Single header row (Chinese labels with units); utf-8-sig BOM so Excel
|
|
|
|
|
+ opens the file with correct encoding.
|
|
|
|
|
+ """
|
|
|
|
|
+ import csv
|
|
|
|
|
+ import io
|
|
|
|
|
+ from urllib.parse import quote
|
|
|
|
|
+
|
|
|
|
|
+ from fastapi.responses import Response
|
|
|
|
|
+
|
|
|
|
|
+ plan = db.query(SimulationPlan).filter(SimulationPlan.id == plan_id).first()
|
|
|
|
|
+ if not plan:
|
|
|
|
|
+ raise HTTPException(status_code=404, detail="Plan not found")
|
|
|
|
|
+
|
|
|
|
|
+ results = (
|
|
|
|
|
+ db.query(SimulationResult)
|
|
|
|
|
+ .filter(SimulationResult.plan_id == plan_id)
|
|
|
|
|
+ .order_by(SimulationResult.run_index.asc())
|
|
|
|
|
+ .all()
|
|
|
)
|
|
)
|
|
|
- tmp.close()
|
|
|
|
|
- write_summary_xlsx(tmp.name, col_keys, col_categories, col_headers, data_rows)
|
|
|
|
|
|
|
+ if not results:
|
|
|
|
|
+ raise HTTPException(status_code=400, detail="No simulation results to export")
|
|
|
|
|
|
|
|
|
|
+ col_keys, _cats, col_headers, data_rows = _collect_export_rows(plan, results)
|
|
|
|
|
+
|
|
|
|
|
+ buf = io.StringIO()
|
|
|
|
|
+ writer = csv.writer(buf)
|
|
|
|
|
+ writer.writerow(col_headers)
|
|
|
|
|
+ for row in data_rows:
|
|
|
|
|
+ writer.writerow([row.get(k, "") for k in col_keys])
|
|
|
|
|
+
|
|
|
|
|
+ ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
|
plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
|
|
plan_name = (plan.name or f"plan_{plan_id}").strip() or f"plan_{plan_id}"
|
|
|
- # RFC 5987: ASCII fallback + UTF-8 filename* for Chinese plan names.
|
|
|
|
|
- ascii_fallback = f"scan_results_{plan_id}_{ts}.xlsx"
|
|
|
|
|
- utf8_name = f"scan_results_{plan_name}_{ts}.xlsx"
|
|
|
|
|
|
|
+ ascii_fallback = f"scan_results_{plan_id}_{ts}.csv"
|
|
|
|
|
+ utf8_name = f"scan_results_{plan_name}_{ts}.csv"
|
|
|
disposition = (
|
|
disposition = (
|
|
|
f"attachment; filename=\"{ascii_fallback}\"; "
|
|
f"attachment; filename=\"{ascii_fallback}\"; "
|
|
|
f"filename*=UTF-8''{quote(utf8_name)}"
|
|
f"filename*=UTF-8''{quote(utf8_name)}"
|
|
|
)
|
|
)
|
|
|
- return FileResponse(
|
|
|
|
|
- tmp.name,
|
|
|
|
|
- media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
|
|
|
|
|
+ return Response(
|
|
|
|
|
+ content=buf.getvalue().encode("utf-8-sig"),
|
|
|
|
|
+ media_type="text/csv",
|
|
|
headers={"Content-Disposition": disposition},
|
|
headers={"Content-Disposition": disposition},
|
|
|
- background=BackgroundTask(os_remove_quiet, tmp.name),
|
|
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
-def os_remove_quiet(path: str) -> None:
|
|
|
|
|
- """Best-effort temp-file cleanup after FileResponse streaming."""
|
|
|
|
|
- import os
|
|
|
|
|
-
|
|
|
|
|
- try:
|
|
|
|
|
- os.remove(path)
|
|
|
|
|
- except OSError:
|
|
|
|
|
- pass
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|
|
|
# One-click simulation start (P0-4)
|
|
# One-click simulation start (P0-4)
|
|
|
# ---------------------------------------------------------------------------
|
|
# ---------------------------------------------------------------------------
|