added export tests feature

This commit is contained in:
2026-07-29 11:26:54 -04:00
parent b07b36cccf
commit 9f141ccc8b
6 changed files with 155 additions and 14 deletions
+61 -2
View File
@@ -2,15 +2,17 @@ from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
import io
import os import os
import zipfile
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import db as db import db as db
from parser import CsvValidationError 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. # Ensure new_scheduler resolves the same DUT/REF labels as db records.
os.environ.setdefault("DUT", db.DUT) 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), "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<n> 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__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True) uvicorn.run("app:app", host="0.0.0.0", port=8000, reload=True)
+6 -2
View File
@@ -37,6 +37,7 @@ class TestRecord:
excluded: bool = False excluded: bool = False
raw_payload: dict[str, Any] | None = None raw_payload: dict[str, Any] | None = None
station_testpoint_map: str | None = None station_testpoint_map: str | None = None
file_path: str | None = None
@dataclass(frozen=True) @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", "excluded", "INTEGER NOT NULL DEFAULT 0")
_ensure_column(conn, "tests", "throttled", "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", "station_testpoint_map", "TEXT")
_ensure_column(conn, "tests", "file_path", "TEXT")
_ensure_column(conn, "schedules", "status_snapshot", "TEXT") _ensure_column(conn, "schedules", "status_snapshot", "TEXT")
conn.execute( conn.execute(
""" """
@@ -186,6 +188,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
int(r.excluded), int(r.excluded),
json.dumps(r.raw_payload or {}), json.dumps(r.raw_payload or {}),
station_testpoint_map, 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( INSERT INTO tests(
test_id, device, test_type, rotation, rx_tx, power_mode, has_coe_pair, 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 ON CONFLICT(test_id, device) DO UPDATE SET
test_type = excluded.test_type, test_type = excluded.test_type,
device = excluded.device, device = excluded.device,
@@ -216,6 +219,7 @@ def upsert_tests(records: list[TestRecord], db_path: str | Path = DB_PATH) -> in
excluded = excluded.excluded, excluded = excluded.excluded,
raw_payload = excluded.raw_payload, raw_payload = excluded.raw_payload,
station_testpoint_map = excluded.station_testpoint_map, station_testpoint_map = excluded.station_testpoint_map,
file_path = excluded.file_path,
updated_at = CURRENT_TIMESTAMP updated_at = CURRENT_TIMESTAMP
""", """,
values, values,
+11
View File
@@ -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 smbclient.open_file(path, mode="r", encoding="utf-8-sig", newline="")
return Path(path).open("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): def join_path(path, name):
if is_unc_path(path): if is_unc_path(path):
base = path.rstrip("\\") base = path.rstrip("\\")
+1
View File
@@ -286,6 +286,7 @@ def process_targets(target_dir, csv_paths, smb_credentials=None, runtime_overrid
status=parsed["status"], status=parsed["status"],
excluded=False, excluded=False,
raw_payload=csv_entry.get("raw_payload", None), raw_payload=csv_entry.get("raw_payload", None),
file_path=join_path(parent_path, filename),
)) ))
+14
View File
@@ -39,6 +39,20 @@ export const api = {
return request('GET', `/schedule/week?${params.toString()}`) return request('GET', `/schedule/week?${params.toString()}`)
}, },
getRerunTests: () => request('GET', '/tests/rerun'), 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 // Transform the flat items array from GET /api/schedule/week into the
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { getDeviceAccentClass } from './TestCard' import { getDeviceAccentClass } from './TestCard'
import { api } from '../api'
const CONFIG_MAP_IMAGE_MODULES = import.meta.glob('../assets/TC*_map/*.png', { const CONFIG_MAP_IMAGE_MODULES = import.meta.glob('../assets/TC*_map/*.png', {
eager: true, eager: true,
@@ -183,6 +184,8 @@ function TestRow({ test }) {
export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) { export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose }) {
const [showConfigMap, setShowConfigMap] = useState(false) const [showConfigMap, setShowConfigMap] = useState(false)
const [isExporting, setIsExporting] = useState(false)
const [exportError, setExportError] = useState(null)
const windowTests = useMemo( const windowTests = useMemo(
() => (Array.isArray(windowDetails?.tests) ? windowDetails.tests : []), () => (Array.isArray(windowDetails?.tests) ? windowDetails.tests : []),
[windowDetails], [windowDetails],
@@ -196,6 +199,10 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
const hasConfigMapImages = configMapImages.length > 0 const hasConfigMapImages = configMapImages.length > 0
const showMapPane = showConfigMap && hasConfigMapImages const showMapPane = showConfigMap && hasConfigMapImages
useEffect(() => {
setExportError(null)
}, [windowDetails])
useEffect(() => { useEffect(() => {
if (!isOpen) return undefined if (!isOpen) return undefined
@@ -209,6 +216,25 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, [isOpen, onClose]) }, [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 ( return (
<div <div
className={`absolute inset-0 z-40 transition-opacity duration-300 ${ className={`absolute inset-0 z-40 transition-opacity duration-300 ${
@@ -238,6 +264,25 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'} {windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
</h2> </h2>
</div> </div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleExport}
disabled={isExporting || !windowDetails?.tests?.length}
className="inline-flex items-center gap-1.5 rounded-full border border-gray-700 px-3 py-2 text-xs font-semibold text-gray-300 hover:border-gray-500 hover:text-white transition-colors disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Export .ini files for this window"
>
{isExporting ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5 animate-spin" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 3v3m0 12v3M4.22 4.22l2.12 2.12m11.32 11.32 2.12 2.12M3 12h3m12 0h3M4.22 19.78l2.12-2.12M17.66 6.34l2.12-2.12" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2M7 10l5 5 5-5M12 15V3" />
</svg>
)}
{isExporting ? 'Exporting…' : 'Export Tests'}
</button>
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -249,6 +294,13 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
</svg> </svg>
</button> </button>
</div> </div>
</div>
{exportError && (
<div className="border-b border-red-900/50 bg-red-950/40 px-5 py-2 text-xs text-red-300">
{exportError}
</div>
)}
{windowDetails && ( {windowDetails && (
<div className="flex-1 min-h-0 overflow-y-auto"> <div className="flex-1 min-h-0 overflow-y-auto">