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 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<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__":
import uvicorn
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
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,
+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 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("\\")
+1
View File
@@ -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),
))
+14
View File
@@ -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
@@ -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 (
<div
className={`absolute inset-0 z-40 transition-opacity duration-300 ${
@@ -238,18 +264,44 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
</h2>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
aria-label="Close panel"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<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
type="button"
onClick={onClose}
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
aria-label="Close panel"
>
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</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 && (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="flex min-h-full min-w-0">