From 9f141ccc8bc2f6d7baf1661b717a2ef6760f0a31 Mon Sep 17 00:00:00 2001 From: Mia Wu Date: Wed, 29 Jul 2026 11:26:54 -0400 Subject: [PATCH] added export tests feature --- backend/src/app.py | 63 +++++++++++++++- backend/src/db.py | 8 ++- backend/src/file_manager.py | 11 +++ backend/src/scanner.py | 1 + frontend/src/api.js | 14 ++++ .../src/components/TestWindowDetailsPanel.jsx | 72 ++++++++++++++++--- 6 files changed, 155 insertions(+), 14 deletions(-) diff --git a/backend/src/app.py b/backend/src/app.py index 14e1c81..f1dd1be 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -2,15 +2,17 @@ from contextlib import asynccontextmanager from pathlib import Path from typing import Any from datetime import date, datetime, timedelta +import io import os +import zipfile -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, HTTPException, Response from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel, Field import db as db from parser import CsvValidationError -from file_manager import resolve_requested_csv_path +from file_manager import resolve_requested_csv_path, read_file_bytes # Ensure new_scheduler resolves the same DUT/REF labels as db records. os.environ.setdefault("DUT", db.DUT) @@ -526,6 +528,63 @@ def get_schedule_week(start: str | None = None, version: int | None = None) -> d "windows": _build_schedule_windows(week_start_date, all_rows, holiday_dates), } +@app.get("/api/schedule/export") +def export_window(window_id: str, version: int | None = None) -> Response: + resolved_version = db.resolve_schedule_version(version, DB_PATH) + if version is not None and resolved_version is None: + raise HTTPException(status_code=404, detail=f"Schedule version {version} was not found") + + all_rows = db.get_schedule_rows(resolved_version, DB_PATH) + if not all_rows: + raise HTTPException(status_code=404, detail="No schedule found") + + holiday_dates = db.list_holidays(DB_PATH) + week_start = min(datetime.strptime(r.scheduled_date, "%Y-%m-%d").date() for r in all_rows) + windows = _build_schedule_windows(week_start, all_rows, holiday_dates) + + target_window = next((w for w in windows if w["window_id"] == window_id), None) + if target_window is None: + raise HTTPException(status_code=404, detail=f"Window '{window_id}' not found") + + wanted = {(t["test_id"], t["device"]) for t in target_window["tests"]} + if not wanted: + raise HTTPException(status_code=400, detail="This window has no scheduled tests") + + # Look up stored file paths from DB — avoids re-scanning and handles R prefixes + with db.get_connection(DB_PATH) as conn: + rows = conn.execute( + "SELECT test_id, device, file_path FROM tests WHERE file_path IS NOT NULL" + ).fetchall() + path_lookup: dict[tuple[str, str], str] = { + (row["test_id"], row["device"]): row["file_path"] for row in rows + } + file_paths = [path_lookup[k] for k in sorted(wanted) if k in path_lookup] + + settings = db.read_settings(DB_PATH) + smb_credentials = _smb_credentials_from_settings(settings) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for fp in file_paths: + try: + data = read_file_bytes(fp, smb_credentials) + except OSError as exc: + print(f"[export] Could not read {fp}: {exc}") + continue + zf.writestr(os.path.basename(fp), data) + + start_date_str = datetime.strptime(target_window["start_date"], "%Y-%m-%d").strftime("%m-%d-%Y") + devices = "_".join(sorted({t["device"] for t in target_window["tests"]})) + suffix = "daytime_tests" if target_window["window_type"] == "daytime" else "tests" + zip_name = f"{start_date_str}_{devices}_{suffix}.zip" + + return Response( + content=buf.getvalue(), + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{zip_name}"'}, + ) + + if __name__ == "__main__": import uvicorn uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True) diff --git a/backend/src/db.py b/backend/src/db.py index 2e85949..317bc02 100644 --- a/backend/src/db.py +++ b/backend/src/db.py @@ -37,6 +37,7 @@ class TestRecord: excluded: bool = False raw_payload: dict[str, Any] | None = None station_testpoint_map: str | None = None + file_path: str | None = None @dataclass(frozen=True) @@ -134,6 +135,7 @@ def init_db(db_path: str | Path = DB_PATH) -> None: _ensure_column(conn, "tests", "excluded", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "throttled", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "tests", "station_testpoint_map", "TEXT") + _ensure_column(conn, "tests", "file_path", "TEXT") _ensure_column(conn, "schedules", "status_snapshot", "TEXT") conn.execute( """ @@ -186,6 +188,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in int(r.excluded), json.dumps(r.raw_payload or {}), station_testpoint_map, + r.file_path, ) ) @@ -194,9 +197,9 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in """ INSERT INTO tests( test_id, device, test_type, rotation, rx_tx, power_mode, has_coe_pair, - coe_pairing_json, config_json, victim_band, throttled, estimated_minutes, status, excluded, raw_payload, station_testpoint_map + coe_pairing_json, config_json, victim_band, throttled, estimated_minutes, status, excluded, raw_payload, station_testpoint_map, file_path ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(test_id, device) DO UPDATE SET test_type = excluded.test_type, device = excluded.device, @@ -216,6 +219,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in excluded = excluded.excluded, raw_payload = excluded.raw_payload, station_testpoint_map = excluded.station_testpoint_map, + file_path = excluded.file_path, updated_at = CURRENT_TIMESTAMP """, values, diff --git a/backend/src/file_manager.py b/backend/src/file_manager.py index 23e5894..7fe8add 100644 --- a/backend/src/file_manager.py +++ b/backend/src/file_manager.py @@ -205,6 +205,17 @@ def open_csv_handle(path: str, smb_credentials: dict[str, Any] | None): return smbclient.open_file(path, mode="r", encoding="utf-8-sig", newline="") return Path(path).open("r", encoding="utf-8-sig", newline="") + +def read_file_bytes(path: str, smb_credentials: dict[str, Any] | None = None) -> bytes: + if is_unc_path(path): + if smbclient is None: + raise ModuleNotFoundError("smbclient is required to read UNC paths") + _register_smb_session_if_needed(path, smb_credentials=smb_credentials) + with smbclient.open_file(path, mode="rb") as fh: + return fh.read() + with open(path, "rb") as fh: + return fh.read() + def join_path(path, name): if is_unc_path(path): base = path.rstrip("\\") diff --git a/backend/src/scanner.py b/backend/src/scanner.py index d539a0e..990e567 100644 --- a/backend/src/scanner.py +++ b/backend/src/scanner.py @@ -286,6 +286,7 @@ def process_targets(target_dir, csv_paths, smb_credentials=None, runtime_overrid status=parsed["status"], excluded=False, raw_payload=csv_entry.get("raw_payload", None), + file_path=join_path(parent_path, filename), )) diff --git a/frontend/src/api.js b/frontend/src/api.js index e072f0b..e429732 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -39,6 +39,20 @@ export const api = { return request('GET', `/schedule/week?${params.toString()}`) }, getRerunTests: () => request('GET', '/tests/rerun'), + + exportWindow: async (windowId) => { + const params = new URLSearchParams({ window_id: windowId }) + const res = await fetch(`${BASE}/schedule/export?${params}`) + if (!res.ok) { + const err = await res.json().catch(() => ({ detail: res.statusText })) + throw new Error(err.detail || `HTTP ${res.status}`) + } + const blob = await res.blob() + const disposition = res.headers.get('Content-Disposition') ?? '' + const match = disposition.match(/filename="([^"]+)"/) + const filename = match ? match[1] : 'tests.zip' + return { blob, filename } + }, } // Transform the flat items array from GET /api/schedule/week into the diff --git a/frontend/src/components/TestWindowDetailsPanel.jsx b/frontend/src/components/TestWindowDetailsPanel.jsx index e2275e4..cf8b08b 100644 --- a/frontend/src/components/TestWindowDetailsPanel.jsx +++ b/frontend/src/components/TestWindowDetailsPanel.jsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from 'react' import { getDeviceAccentClass } from './TestCard' +import { api } from '../api' const CONFIG_MAP_IMAGE_MODULES = import.meta.glob('../assets/TC*_map/*.png', { eager: true, @@ -183,6 +184,8 @@ function TestRow({ test }) { export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) { const [showConfigMap, setShowConfigMap] = useState(false) + const [isExporting, setIsExporting] = useState(false) + const [exportError, setExportError] = useState(null) const windowTests = useMemo( () => (Array.isArray(windowDetails?.tests) ? windowDetails.tests : []), [windowDetails], @@ -196,6 +199,10 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose const hasConfigMapImages = configMapImages.length > 0 const showMapPane = showConfigMap && hasConfigMapImages + useEffect(() => { + setExportError(null) + }, [windowDetails]) + useEffect(() => { if (!isOpen) return undefined @@ -209,6 +216,25 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose return () => window.removeEventListener('keydown', handleKeyDown) }, [isOpen, onClose]) + async function handleExport() { + if (!windowDetails?.window_id) return + setIsExporting(true) + setExportError(null) + try { + const { blob, filename } = await api.exportWindow(windowDetails.window_id) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = filename + a.click() + URL.revokeObjectURL(url) + } catch (err) { + setExportError(err.message || 'Export failed') + } finally { + setIsExporting(false) + } + } + return (
- +
+ + +
+ {exportError && ( +
+ {exportError} +
+ )} + {windowDetails && (