added export tests feature
This commit is contained in:
+61
-2
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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("\\")
|
||||||
|
|||||||
@@ -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),
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,18 +264,44 @@ export default function TestWindowDetailsPanel({ isOpen, windowDetails, onClose
|
|||||||
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
|
{windowDetails ? formatWindowHeader(windowDetails) : 'Window details'}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={onClose}
|
type="button"
|
||||||
className="rounded-full border border-gray-700 p-2 text-gray-400 hover:border-gray-500 hover:text-white transition-colors"
|
onClick={handleExport}
|
||||||
aria-label="Close panel"
|
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"
|
||||||
<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">
|
aria-label="Export .ini files for this window"
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
>
|
||||||
</svg>
|
{isExporting ? (
|
||||||
</button>
|
<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>
|
</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">
|
||||||
<div className="flex min-h-full min-w-0">
|
<div className="flex min-h-full min-w-0">
|
||||||
|
|||||||
Reference in New Issue
Block a user